From c08ecad9e17abfcbaf4f245fffe93fd0094dfc09 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 4 Oct 2026 17:50:51 -0600 Subject: [PATCH 1/6] feat: add AgentHook system and GPT-Live phone path via PhoneCall --- .claude/rules/voice-components.md | 22 +- config/example.yaml | 7 +- .../Audit/AuditConversationObserver.cs | 59 - .../Audit/QueuedAuditSink.cs | 39 +- .../Compilation/AgentApproval.cs | 61 +- .../Compilation/AgentCompilationContext.cs | 15 +- .../AgentContextProviderCompiler.cs | 11 +- .../Compilation/AgentDelegationTool.cs | 3 +- .../Compilation/AgentHarnessProviders.cs | 49 +- .../Configuration/Compilation/AgentsWalk.cs | 7 +- .../Compilation/CompileTableRow.cs | 4 +- .../Compilation/CompiledAgent.cs | 8 +- .../Compilation/CompiledDocument.cs | 7 +- .../Compilation/ConfigurationCompiler.cs | 39 +- .../Compilation/ExplicitGraphRow.cs | 6 +- .../Compilation/PatternGraphRow.cs | 9 +- .../Configuration/Compilation/TurnLayers.cs | 21 +- .../ConversationProviderConfiguration.cs | 13 + .../Schema/ProvidersConfiguration.cs | 40 +- .../Schema/VendorProviderConfiguration.cs | 2 +- .../Schema/agentcore-v1.schema.json | 10 +- .../Conversation/ConversationRecord.cs | 6 +- .../Conversation/ConversationStoreFactory.cs | 2 +- .../Conversation/Conversations.cs | 6 + .../Memory/InMemoryConversationStates.cs | 38 + .../Memory/InMemoryConversationStore.cs | 130 +- src/AgentCore.Application/Diagnostics/Log.cs | 34 +- .../LoggingConversationObserver.cs | 111 -- .../TelemetryConversationObserver.cs | 76 -- .../Evaluation/ModerationVerdict.cs | 44 - .../Evaluation/PromptModerator.cs | 38 - src/AgentCore.Application/Hooks/AgentHook.cs | 278 +++++ .../Hooks/BuiltIn/AuditHook.cs | 126 ++ .../Hooks/BuiltIn/AuditRules.cs | 21 + .../Hooks/BuiltIn/CallBriefHook.cs | 61 + .../Hooks/BuiltIn/LoggingHook.cs | 85 ++ .../Hooks/BuiltIn/TelemetryHook.cs | 100 ++ .../Hooks/BuiltIn/TurnFailureAccounting.cs | 102 ++ .../Hooks/BuiltInHooks.cs | 22 + .../Hooks/Engine/ApprovalDecision.cs | 7 + .../Hooks/Engine/Delivery.cs | 7 + .../Hooks/Engine/GateRunner.cs | 178 +++ .../Hooks/Engine/HookLog.cs | 100 ++ .../Hooks/Engine/HookReader.cs | 295 +++++ .../Hooks/Engine/HookRuntime.cs | 82 ++ .../Hooks/Engine/HookScopes.cs | 11 + .../Hooks/Engine/HookTable.cs | 115 ++ .../Hooks/Engine/Mailbox.cs | 205 ++++ .../Hooks/Engine/NoticeDispatch.cs | 45 + .../Hooks/Engine/NoticeHub.cs | 193 +++ .../Hooks/Engine/SessionHooks.cs | 126 ++ .../Hooks/Engine/TurnRounds.cs | 13 + src/AgentCore.Application/Hooks/GatePoint.cs | 81 ++ .../Hooks/Gates/ApprovalGate.cs | 59 + .../Hooks/Gates/CallGate.cs | 77 ++ .../Hooks/Gates/CallRefusal.cs | 18 + .../Hooks/Gates/CompactionGate.cs | 39 + .../Hooks/Gates/EntryGate.cs | 63 + .../Hooks/Gates/EntryTransport.cs | 12 + .../Hooks/Gates/ModelFailureGate.cs | 72 ++ .../Hooks/Gates/ModelGate.cs | 68 ++ .../Hooks/Gates/ModelResultGate.cs | 38 + .../Hooks/Gates/RunEndGate.cs | 54 + .../Hooks/Gates/RunGate.cs | 66 ++ .../Hooks/Gates/ToolFailureGate.cs | 76 ++ .../Hooks/Gates/ToolGate.cs | 83 ++ .../Hooks/Gates/ToolResultGate.cs | 59 + .../Hooks/Gates/TurnGate.cs | 62 + .../Hooks/HookFailure.cs | 12 + src/AgentCore.Application/Hooks/HookGate.cs | 71 ++ src/AgentCore.Application/Hooks/HookNotice.cs | 10 + src/AgentCore.Application/Hooks/HookScope.cs | 28 + .../Hooks/Layers/ApprovalGateLayer.cs | 280 +++++ .../Hooks/Layers/CallGateChain.cs | 67 ++ .../Hooks/Layers/EntryGateChain.cs | 53 + .../Hooks/Layers/GatedSummariser.cs | 85 ++ .../Hooks/Layers/ModelHookChatClient.cs | 235 ++++ .../Hooks/Layers/ModelRound.cs | 56 + .../Hooks/Layers/ModelRoundGates.cs | 70 ++ .../Hooks/Layers/RunEndGateChain.cs | 45 + .../Hooks/Layers/RunGateProvider.cs | 51 + .../Hooks/Layers/ToolCallOutcomes.cs | 32 + .../Hooks/Layers/ToolHookMiddleware.cs | 245 ++++ .../Hooks/Layers/TurnGateAgent.cs | 97 ++ .../Hooks/Notices/ApprovalBy.cs | 15 + .../Hooks/Notices/ApprovalChanged.cs | 18 + .../Hooks/Notices/ApprovalState.cs | 15 + .../Hooks/Notices/CallEnd.cs | 8 + .../Hooks/Notices/CallEndReason.cs | 18 + .../Hooks/Notices/CallEnded.cs | 19 + .../Hooks/Notices/CallStarted.cs | 16 + .../Hooks/Notices/Compacted.cs | 14 + .../Hooks/Notices/CompactionOutcome.cs | 18 + .../Hooks/Notices/ConversationEnded.cs | 16 + .../Hooks/Notices/ConversationOrigin.cs | 21 + .../Hooks/Notices/ConversationStarted.cs | 7 + .../Hooks/Notices/ConversationUnloaded.cs | 7 + .../Hooks/Notices/Fault.cs | 9 + .../Hooks/Notices/FaultKind.cs | 30 + .../Hooks/Notices/FilePublished.cs | 16 + .../Hooks/Notices/HostStarted.cs | 8 + .../Hooks/Notices/HostStopping.cs | 6 + .../Hooks/Notices/InputModerated.cs | 14 + .../Hooks/Notices/InputVerdict.cs | 15 + .../Hooks/Notices/KnowledgeSearched.cs | 20 + .../Hooks/Notices/LatencyMetric.cs | 18 + .../Hooks/Notices/LineSpoken.cs | 16 + .../Hooks/Notices/ModelCalled.cs | 30 + .../Notices/ModerationUnavailableReason.cs | 12 + .../Hooks/Notices/ReplyCut.cs | 14 + .../Hooks/Notices/ReplyUpdated.cs | 8 + .../Hooks/Notices/RetentionSwept.cs | 7 + .../Hooks/Notices/SkillLoaded.cs | 8 + .../Hooks/Notices/Speaker.cs | 12 + .../Hooks/Notices/StageChanged.cs | 8 + .../Hooks/Notices/SubagentEnded.cs | 19 + .../Hooks/Notices/SubagentOutcome.cs | 15 + .../Hooks/Notices/SubagentStarted.cs | 11 + .../Hooks/Notices/ToolCalled.cs | 24 + .../Hooks/Notices/ToolOutcome.cs | 27 + .../Hooks/Notices/TurnCompleted.cs | 26 + .../Hooks/Notices/TurnLatency.cs | 8 + .../Hooks/Notices/TurnOutcome.cs | 24 + .../Hooks/Notices/TurnRefusal.cs | 27 + .../Hooks/Notices/TurnRefusalTokens.cs | 25 + .../Hooks/Notices/TurnRefused.cs | 12 + .../Hooks/Notices/TurnStarted.cs | 14 + .../Hooks/Notices/TurnSuperseded.cs | 8 + .../Hooks/Notices/UnloadCause.cs | 12 + .../Hooks/Notices/VoiceParty.cs | 12 + .../Hooks/Notices/VoiceState.cs | 18 + .../Hooks/Notices/VoiceStateChanged.cs | 14 + .../Knowledge/ClaimedProbe.cs | 2 +- .../Knowledge/FacetFilteredSearch.cs | 2 +- .../Knowledge/KnowledgePrefetchProvider.cs | 1 - .../Knowledge/KnowledgeProbe.cs | 47 +- .../Knowledge/KnowledgeSearch.cs | 44 +- .../Llm/ConversationRequestStamp.cs | 2 +- .../Ports/IConversationObserver.cs | 48 - .../Ports/IConversationPort.cs | 12 +- .../Ports/IConversationSessionFactory.cs | 2 +- .../Ports/IConversationSessions.cs | 4 +- .../Ports/IConversationStore.cs | 18 +- .../Ports/IConversationStoreAdapter.cs | 2 +- .../Ports/IConversationTitler.cs | 2 +- .../Ports/ISourcePort.cs | 14 - .../PublicAPI.Unshipped.txt | 1047 +++++++++++++++-- .../Runtime/AgentCoreAgent.cs | 9 +- .../{ => Agents}/ConversationTurnRunner.cs | 55 +- .../Runtime/{ => Agents}/DelegatedAgentRun.cs | 49 +- .../Runtime/{ => Agents}/FallbackAgent.cs | 11 +- .../{ => Agents/Graph}/GraphFaultAgent.cs | 2 +- .../Agents/Graph/GraphParticipantAgent.cs | 135 +++ .../{ => Agents/Graph}/GraphStateAgent.cs | 6 +- .../Runtime/{ => Agents}/ModerationAgent.cs | 49 +- .../{ => Agents}/RequireOutputAgent.cs | 8 +- .../{ => Clarification}/ClarificationText.cs | 4 +- .../{ => Clarification}/Clarifications.cs | 24 +- .../Compaction/CompactionStrategyFactory.cs | 9 +- .../Runtime/Compaction/SummaryRowProvider.cs | 43 +- .../Runtime/ConversationEvent.cs | 40 - .../Runtime/ConversationEventChain.cs | 275 ----- .../Runtime/ConversationEventKind.cs | 62 - .../Runtime/ConversationEventKinds.cs | 63 - .../Runtime/ConversationEventPayloadKeys.cs | 13 - .../Runtime/ConversationObserverDispatcher.cs | 209 ---- .../Runtime/ConversationObservers.cs | 84 -- .../Runtime/ConversationSessionLifetime.cs | 194 --- .../{ => Cut}/ConversationCutTracker.cs | 43 +- .../Runtime/{ => Cut}/TurnCut.cs | 2 +- .../Runtime/{ => Cut}/TurnCutSlot.cs | 4 +- .../Runtime/Harness/ApprovalAnswerHold.cs | 95 ++ .../Runtime/Harness/ApprovalQueueDrain.cs | 108 ++ .../Runtime/Harness/BackgroundChildAgent.cs | 6 +- .../Runtime/Harness/CallerTimeZone.cs | 1 + .../Runtime/Harness/PendingApprovalQueue.cs | 124 +- .../{ => Session}/AgentCoreAgentSession.cs | 2 +- .../{ => Session}/ConversationBusyMark.cs | 16 +- .../Runtime/Session/ConversationCleanup.cs | 67 ++ .../Runtime/Session/ConversationEnding.cs | 346 ++++++ .../{ => Session}/ConversationSession.cs | 93 +- .../ConversationSessionFactory.cs | 56 +- .../Session/ConversationSessionLifetime.cs | 319 +++++ .../{ => Session}/ConversationSessionSeams.cs | 7 +- .../ConversationSessionStateStore.cs | 23 +- .../{ => Session}/ConversationStoreReads.cs | 24 +- .../ConversationTranscriptLedger.cs | 46 +- .../{ => Session}/ConversationWorkspace.cs | 2 +- .../Runtime/Session/SessionFaults.cs | 15 + .../AuditingFunctionInvokingChatClient.cs | 168 ++- .../Runtime/ToolCalls/ConversationToolRuns.cs | 242 ++++ .../Runtime/ToolCalls/FinishedToolPairs.cs | 55 + .../{ => ToolCalls}/ModelFacingChatClient.cs | 3 +- .../Runtime/ToolCalls/ReturnedToolCalls.cs | 46 + .../Runtime/{ => ToolCalls}/ToolCallScopes.cs | 5 +- .../Runtime/{ => ToolCalls}/ToolFaultMark.cs | 4 +- .../Runtime/ToolCalls/ToolRun.cs | 24 + .../Runtime/{ => Turn}/ConversationTurn.cs | 11 +- .../{ => Turn}/ConversationTurnOrigin.cs | 2 +- .../Runtime/Turn/FileNotice.cs | 19 + .../Runtime/Turn/ITurnAttachments.cs | 11 +- .../Lifecycle}/ConversationTurnAgent.cs | 40 +- .../Lifecycle}/ConversationTurnCommit.cs | 68 +- .../Lifecycle}/ConversationTurnCompletion.cs | 115 +- .../Lifecycle}/ConversationTurnStream.cs | 54 +- .../Lifecycle}/ConversationTurnWriters.cs | 6 +- .../{ => Turn/Lifecycle}/ITurnCompleter.cs | 3 +- .../{ => Turn/Lifecycle}/ReplyOutcome.cs | 8 +- .../Runtime/{ => Turn/Lifecycle}/ReplyText.cs | 2 +- .../{ => Turn/Lifecycle}/TurnDisposition.cs | 14 +- .../Turn/Lifecycle/TurnFailureReasons.cs | 31 + .../Runtime/Turn/Lifecycle/TurnOutcomes.cs | 36 + .../Runtime/Turn/Lifecycle/TurnRefusals.cs | 35 + .../Lifecycle}/TurnReplyResolution.cs | 40 +- .../Runtime/{ => Turn/Lifecycle}/TurnRun.cs | 3 +- .../Runtime/Turn/OuterCall.cs | 30 - .../Runtime/Turn/TurnApprovalAnswers.cs | 81 ++ .../Runtime/Turn/TurnAttachments.cs | 56 +- .../Runtime/Turn/TurnCommittedContent.cs | 2 +- .../Runtime/Turn/TurnContextProvider.cs | 17 +- .../Runtime/Turn/TurnFiles.cs | 46 +- .../Runtime/{ => Turn}/TurnInvocation.cs | 49 +- .../Runtime/Turn/TurnMessages.cs | 30 + .../Runtime/Turn/TurnResults.cs | 52 + .../Runtime/Turn/TurnSources.cs | 11 +- .../Runtime/TurnRefusals.cs | 47 - .../Secrets/KnownSecrets.cs | 9 + .../Sessions/Memory/HeldSession.cs | 2 +- .../Memory/InMemoryConversationSessions.cs | 28 +- .../Sessions/Memory/WorkspaceRootSweeper.cs | 2 +- .../Skills/PinnedSkillRedirect.cs | 4 +- .../Skills/PinnedSkillsProvider.cs | 15 +- .../State/StateExtractor.cs | 30 +- .../Tools/Binding/ToolParameterBindings.cs | 3 +- .../Tools/Builtin/FilePublishTool.cs | 15 +- src/AgentCore.Application/Tools/CachedTool.cs | 2 + .../Tools/TimeLimitedTool.cs | 2 + .../Tools/ToolCallScope.cs | 9 +- .../AgentCoreChatHistoryProvider.cs | 75 +- .../Transcript/ConversationGate.cs | 24 +- .../Transcript/ConversationGates.cs | 2 +- .../Transcript/ConversationMessage.cs | 6 +- .../Transcript/ConversationTranscript.cs | 40 +- .../Transcript/ConversationWrites.cs | 32 +- .../Transcript/FrontVoice.cs | 22 + .../Transcript/ITranscriptLossCounter.cs | 2 +- .../Transcript/TranscriptCatchUp.cs | 10 +- .../Transcript/TranscriptLoss.cs | 2 +- .../Transcript/TranscriptLosses.cs | 2 +- .../Transcript/TranscriptPosition.cs | 2 +- .../Transcript/TurnCommit.cs | 29 +- .../Transcript/TurnWords.cs | 62 +- src/AgentCore.AspNetCore/Calls/CallAnswer.cs | 28 + src/AgentCore.AspNetCore/Calls/CallAsk.cs | 48 + src/AgentCore.AspNetCore/Calls/CallAsks.cs | 222 ++++ .../Calls/CallConversationLostException.cs | 9 + .../Calls/CallEndNotice.cs | 102 ++ src/AgentCore.AspNetCore/Calls/CallLog.cs | 42 + src/AgentCore.AspNetCore/Calls/PhoneCall.cs | 395 +++++++ .../Calls/PhoneCallAdmission.cs | 22 + .../Calls/PhoneCallHost.cs | 48 + .../Conversation/ConversationSweeper.cs | 4 + .../DependencyInjection/AgentCoreBoot.cs | 44 +- .../AgentCoreBootService.cs | 18 +- .../AgentCoreHookOptions.cs | 45 + .../DependencyInjection/AgentCoreOptions.cs | 25 +- .../AgentCoreServiceCollectionExtensions.cs | 5 + .../Startup/ConversationSeamStartup.cs | 60 +- .../Startup/ConversationSessionStartup.cs | 48 +- .../Startup/ConversationStartup.cs | 2 +- .../Startup/EntryRegistry.cs | 8 - .../Startup/EntryRouteCheck.cs | 38 +- .../Startup/HookStartup.cs | 63 + .../Endpoints/AgentCoreEntries.cs | 63 +- .../Endpoints/AgentCoreRouteMetadata.cs | 21 +- ...ctorEndpointConventionBuilderExtensions.cs | 20 - .../Endpoints/EntrySelectorMetadata.cs | 6 - .../Endpoints/IEntryFeature.cs | 9 - .../Endpoints/IEntrySelector.cs | 17 - .../Endpoints/ResponsesContracts.cs | 3 +- .../Endpoints/ResponsesEndpoints.cs | 13 +- .../Endpoints/ResponsesPendingTurn.cs | 1 + .../Endpoints/ResponsesTurn.cs | 4 +- .../Endpoints/ResponsesTurnConflict.cs | 6 +- .../Endpoints/ResponsesTurnStream.cs | 14 +- .../PublicAPI.Unshipped.txt | 273 +++-- .../Sessions/AgentCoreAgentSessionStore.cs | 3 +- .../OpenAiLive/Call/CommentaryPieces.cs | 56 + .../Vendors/OpenAiLive/Call/LiveCallEnd.cs | 127 ++ .../OpenAiLive/Call/LiveInstructions.cs | 25 + .../Vendors/OpenAiLive/Call/LiveLine.cs | 11 + .../OpenAiLive/Call/LiveTranscriptLedger.cs | 97 ++ .../Vendors/OpenAiLive/Call/OpenAiLiveCall.cs | 372 ++++++ .../Vendors/OpenAiLive/OpenAiLiveCalls.cs | 164 +++ .../OpenAiLiveConversationAdapter.cs | 100 ++ .../OpenAiLive/OpenAiLiveCredentials.cs | 32 + .../Vendors/OpenAiLive/OpenAiLiveLog.cs | 48 + .../Vendors/OpenAiLive/OpenAiLiveSettings.cs | 76 ++ .../OpenAiLive/Webhook/AcceptOutcome.cs | 14 + .../OpenAiLive/Webhook/LiveIncomingCall.cs | 10 + .../OpenAiLive/Webhook/OpenAiLiveWebhook.cs | 207 ++++ .../Webhook/StandardWebhookSignature.cs | 62 + .../Vendors/OpenAiLive/Wire/ILiveSideband.cs | 11 + .../Vendors/OpenAiLive/Wire/LiveAttach.cs | 8 + .../Vendors/OpenAiLive/Wire/LiveEvent.cs | 33 + .../OpenAiLive/Wire/LiveEventReader.cs | 60 + .../OpenAiLive/Wire/OpenAiLiveControl.cs | 83 ++ .../OpenAiLive/Wire/OpenAiLiveEvents.cs | 39 + .../Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs | 154 +++ .../OpenAiLive/Wire/WebSocketSideband.cs | 81 ++ .../Vendors/Telnyx/.gitkeep | 0 .../{ => Connection}/OutboundItem.cs | 3 +- .../{ => Connection}/TelnyxRelayConnection.cs | 74 +- .../TelnyxRelayConversationChannelFactory.cs | 11 +- .../{ => Connection}/TelnyxRelayInput.cs | 12 +- .../{ => Connection}/TelnyxRelayOutput.cs | 6 +- .../TelnyxRelayConversationAdapter.cs | 20 +- .../TelnyxRelay/TelnyxRelayEndpoints.cs | 3 +- .../Vendors/TelnyxRelay/TelnyxRelayLog.cs | 7 +- .../Vendors/TelnyxRelay/TelnyxRelayOptions.cs | 3 +- .../TelnyxRelay/TelnyxRelaySpeechAdapter.cs | 3 +- .../{ => Wire}/RelayProtocolException.cs | 2 +- .../{ => Wire}/TelnyxRelayContracts.cs | 24 +- .../{ => Wire}/TelnyxRelayFrameReader.cs | 31 +- .../TelnyxRelay/{ => Wire}/TelnyxRelayJson.cs | 7 +- .../Voice/ConversationChannel.cs | 73 -- .../{ => Diagnostics}/ConversationRouteLog.cs | 2 +- .../{ => Diagnostics}/VoiceConversationLog.cs | 32 +- .../Voice/{ => Filler}/FillerOptions.cs | 6 +- .../Voice/{ => Filler}/FillerScheduler.cs | 10 +- .../Voice/{ => Filler}/FillerSource.cs | 4 +- .../Voice/{ => Filler}/ToolFillerScope.cs | 11 +- .../Voice/{ => Options}/BoundedTimerLimits.cs | 2 +- .../Voice/{ => Options}/VoiceOptions.cs | 6 +- .../Voice/{ => Options}/VoiceOptionsBinder.cs | 4 +- .../Voice/Ports/CallTraits.cs | 16 + .../Voice/{ => Ports}/ConversationInput.cs | 17 +- .../Voice/{ => Ports}/FrameOutcome.cs | 2 +- .../Voice/{ => Ports}/FrameParser.cs | 2 +- .../Voice/{ => Ports}/IConversationAdapter.cs | 5 +- .../{ => Ports}/IConversationInputPort.cs | 4 +- .../{ => Ports}/IConversationOutputPort.cs | 2 +- .../IConversationTransportAdapter.cs | 3 +- .../Voice/{ => Ports}/ISpeechAdapter.cs | 2 +- .../Voice/Routing/ConversationChannel.cs | 32 + .../ConversationChannelContext.cs | 2 +- ...versationEndpointRouteBuilderExtensions.cs | 14 +- .../Voice/{ => Routing}/ConversationSeams.cs | 2 +- .../IConversationChannelFactory.cs | 2 +- .../Voice/{ => Session}/AgentState.cs | 2 +- .../Voice/{ => Session}/AgentStateChanged.cs | 2 +- .../Voice/{ => Session}/UserAwayOptions.cs | 10 +- .../Voice/{ => Session}/UserAwayTimer.cs | 2 +- .../Voice/{ => Session}/UserState.cs | 4 +- .../Voice/{ => Session}/UserStateChanged.cs | 2 +- .../Voice/Session/VoiceNotices.cs | 45 + .../Voice/{ => Session}/VoiceSession.cs | 15 +- .../VoiceSessionClosedException.cs | 2 +- .../Voice/{ => Speech}/BackgroundSpeeches.cs | 7 +- .../{ => Speech/Replies}/EngineReplyStream.cs | 23 +- .../{ => Speech/Replies}/PipelineReply.cs | 13 +- .../Voice/{ => Speech/Replies}/ReplyEvent.cs | 2 +- .../{ => Speech/Replies}/ReplyEventKind.cs | 4 +- .../{ => Speech/Replies}/ReplyHearing.cs | 139 ++- .../Voice/{ => Speech/Replies}/SayReply.cs | 7 +- .../{ => Speech/Replies}/TextForwarding.cs | 4 +- .../Replies}/TextForwardingResult.cs | 2 +- .../{ => Speech/Replies}/TextPlayback.cs | 2 +- .../Voice/{ => Speech}/SpeechDoneCallbacks.cs | 7 +- .../Voice/{ => Speech}/SpeechHandle.cs | 5 +- .../Voice/{ => Speech}/SpeechPriority.cs | 2 +- .../Voice/{ => Speech}/SpeechQueue.cs | 2 +- .../Voice/{ => Speech}/SpeechScheduler.cs | 5 +- .../Voice/{ => Speech}/SpeechTasks.cs | 2 +- .../Voice/{ => Threading}/AsyncEvent.cs | 2 +- .../Voice/{ => Threading}/TaskTeardown.cs | 2 +- .../{ => Transport}/ConnectionTaskObserver.cs | 6 +- .../{ => Transport}/JsonWebSocketPump.cs | 18 +- .../JsonWebSocketPumpOptions.cs | 2 +- .../{ => Transport}/JsonWebSocketSender.cs | 2 +- .../{ => Turns}/ConversationSpeechPairing.cs | 3 +- .../Voice/{ => Turns}/InterruptionSource.cs | 2 +- .../Voice/{ => Turns}/TurnMetrics.cs | 36 +- .../Voice/{ => Turns}/UserTurnHandler.cs | 11 +- .../Voice/{ => Turns}/VoiceActivity.cs | 104 +- .../Voice/Turns/VoiceConversationLoop.cs | 409 +++++++ .../Voice/VoiceConversationLoop.cs | 284 ----- .../Audit/AuditEventVocabulary.cs | 4 +- src/AgentCore.Domain/Audit/AuditHash.cs | 2 +- .../Audit/AuditPayloadKeys.cs | 5 +- .../Audit/ConversationEndReason.cs | 45 - src/AgentCore.Domain/TurnResult.cs | 31 +- .../AgentCoreHostBuilderExtensions.cs | 21 +- .../AgentCoreHostEndpointExtensions.cs | 8 +- .../Audit/Postgres/PostgresAuditSink.cs | 2 +- .../Postgres/PostgresConversationStore.cs | 18 +- .../Postgres/PostgresConversationStoreSql.cs | 11 +- .../PostgresConversationVerification.cs | 6 +- .../Postgres/PostgresConversationWords.cs | 4 +- .../Migrations/003_response_continuation.sql | 2 +- .../Telephony/Telnyx/.gitkeep | 0 .../Audit/AuditConversationObserverTests.cs | 276 ----- ...ationSessionAuditConversationEndedTests.cs | 72 +- .../ConversationSessionAuditEventsTests.cs | 43 +- .../ConversationSessionAuditSinkTests.cs | 44 +- .../ConversationSessionAuditTestSupport.cs | 21 +- ...onversationSessionAuditToolFailureTests.cs | 111 +- .../ConversationSessionModerationTests.cs | 54 +- ...rsationSessionMultiStepAuditEventsTests.cs | 17 +- .../Audit/QueuedAuditSinkTests.cs | 57 +- .../Compilation/ApprovalCompilationTests.cs | 6 +- .../ClarificationWiringBindingTests.cs | 5 +- ...igurationCompilerConversationStampTests.cs | 2 +- .../ConfigurationCompilerModelFacingTests.cs | 4 +- .../Compilation/FilesCompilationTests.cs | 2 +- .../Compilation/GraphNoMatchTests.cs | 20 +- .../Compilation/GuardedGraphEdgeTests.cs | 30 +- .../Compilation/MemoryCompilationTests.cs | 4 +- .../ConfigurationLoaderKnowledgeTests.cs | 222 ++++ .../ConfigurationLoaderParsingTests.cs | 108 ++ .../ConfigurationLoaderSpokenLineTests.cs | 240 ++++ .../Configuration/ConfigurationLoaderTests.cs | 545 +-------- .../Configuration/ConversationSchemaTests.cs | 37 +- .../Configuration/ExampleDocument.cs | 490 +------- .../ExampleDocumentAgentsJson.cs | 252 ++++ .../Configuration/ExampleDocumentStateJson.cs | 229 ++++ .../InMemoryConversationStoreStateTests.cs | 30 + .../InMemoryConversationStoreTests.cs | 2 +- ...nMemoryConversationStoreTranscriptTests.cs | 2 +- .../InMemoryConversationStoreWindowTests.cs | 2 +- .../Diagnostics/DiagnosticsFakes.cs | 14 +- .../LibraryOpenTelemetryHarness.cs | 113 ++ .../Diagnostics/LibraryOpenTelemetryTests.cs | 173 +-- .../LoggingConversationObserverTests.cs | 205 ---- .../TelemetryConversationObserverTests.cs | 161 --- .../Diagnostics/TurnLogOnceTests.cs | 15 +- .../Diagnostics/TurnMetricsTests.cs | 13 +- .../Diagnostics/TurnObservabilityHarness.cs | 8 +- .../Diagnostics/TurnSpanTests.cs | 10 +- .../Fakes/NewestWordsToolChatClient.cs | 78 ++ .../Fakes/ParallelToolChatClient.cs | 64 + .../Fakes/RecordingBlobStore.cs | 6 +- .../Fakes/RequestRecordingChatClient.cs | 6 +- .../Fakes/ToolCallingChatClient.cs | 18 +- .../Fakes/TurnScriptChatClient.cs | 15 +- .../Hooks/ApprovalAnswerNoticeTests.cs | 109 ++ .../Hooks/ApprovalDenialRoundsTests.cs | 159 +++ .../Hooks/ApprovalGateTests.cs | 200 ++++ .../Hooks/ApprovalTools.cs | 75 ++ .../Hooks/AuditHookTests.cs | 152 +++ .../Hooks/BeforeGates.cs | 34 + .../Hooks/BuiltInHooksTests.cs | 19 + .../Hooks/CallBriefHookTests.cs | 106 ++ .../Hooks/CallEndNoticeTests.cs | 80 ++ .../Hooks/CallGateTests.cs | 151 +++ .../Hooks/CompactionGateTests.cs | 144 +++ .../Hooks/ConversationEndNoticeTests.cs | 266 +++++ .../Hooks/ConversationEndReloadTests.cs | 170 +++ .../Hooks/CutAndFaultNoticeTests.cs | 77 ++ .../Hooks/DecidingHook.cs | 34 + .../Hooks/FlakyModel.cs | 57 + .../Hooks/GateOverheadSuite.cs | 12 + .../Hooks/GateOverheadTests.cs | 112 ++ .../Hooks/GatePointTests.cs | 51 + .../Hooks/GateRunnerTests.cs | 252 ++++ .../Hooks/GraphParticipantHookTests.cs | 141 +++ .../Hooks/HookGateLazyInputTests.cs | 61 + .../Hooks/HookGateTests.cs | 112 ++ .../Hooks/HookSessions.cs | 213 ++++ .../Hooks/HookTableTests.cs | 81 ++ .../Hooks/LoggingHookTests.cs | 111 ++ .../Hooks/ModelCalledNoticeTests.cs | 82 ++ .../Hooks/ModelFailureGateTests.cs | 163 +++ .../Hooks/ModelGateTests.cs | 281 +++++ .../Hooks/ModerationNoticeTests.cs | 94 ++ .../Hooks/NoticeHubCostTests.cs | 55 + .../Hooks/NoticeHubLifetimeTests.cs | 248 ++++ .../Hooks/NoticeHubTests.cs | 178 +++ .../Hooks/NoticeHubTimeoutTests.cs | 151 +++ .../Hooks/NoticeOrderTests.cs | 97 ++ .../Hooks/NoticeProbes.cs | 44 + .../Hooks/Recorder.cs | 70 ++ .../Hooks/RequestsSeen.cs | 51 + .../Hooks/RunEndGateTests.cs | 306 +++++ .../Hooks/RunFactNoticeTests.cs | 172 +++ .../Hooks/RunGateTests.cs | 208 ++++ .../Hooks/SessionFaultNoticeTests.cs | 145 +++ .../Hooks/SessionHooksTests.cs | 249 ++++ .../Hooks/SkillLoadedNoticeTests.cs | 74 ++ .../Hooks/SubagentBracketTests.cs | 121 ++ .../Hooks/TelemetryHookSuite.cs | 13 + .../Hooks/TelemetryHookTests.cs | 167 +++ .../Hooks/ToolCalledNoticeTests.cs | 97 ++ .../Hooks/ToolGateTests.cs | 250 ++++ .../Hooks/ToolHookStackingTests.cs | 113 ++ .../Hooks/TurnGateTests.cs | 159 +++ .../Hooks/TurnNoticeTests.cs | 162 +++ .../Hooks/TurnOutcomesTests.cs | 43 + .../Hooks/TurnRefusalTokensTests.cs | 28 + .../Hooks/TurnRefusedNoticeTests.cs | 108 ++ .../Hooks/UnloadNoticeTests.cs | 164 +++ .../Hooks/UsageModel.cs | 34 + .../Hooks/WarningThrowingLogger.cs | 16 + .../Knowledge/FacetFilterTests.cs | 2 +- .../Knowledge/Fakes/StubKnowledgePort.cs | 25 +- .../Knowledge/KnowledgeProbeTests.cs | 73 +- .../KnowledgeProviderFactoryCitationTests.cs | 27 +- .../KnowledgeProviderFactoryLimitTests.cs | 15 +- .../KnowledgeProviderFactorySearchTests.cs | 2 +- .../KnowledgeProviderFactoryTestSupport.cs | 1 - .../Runtime/AgentCoreAgentSessionTests.cs | 34 +- .../Runtime/AgentCoreAgentTestSupport.cs | 10 +- .../Runtime/AgentCoreAgentTests.cs | 8 +- .../AgentDelegationBackgroundReleaseTests.cs | 19 +- ...nctionInvokingChatClientCallRecordTests.cs | 3 +- ...ctionInvokingChatClientErrorPolicyTests.cs | 91 +- ...ingFunctionInvokingChatClientStripTests.cs | 24 +- .../Runtime/ClarificationTextTests.cs | 5 +- .../Runtime/ClarificationsTests.cs | 30 +- ...ersationTurnStreamCompactionNoticeTests.cs | 4 +- .../SummaryRowProviderNoticeTests.cs | 4 +- .../Runtime/ConversationBusyMarkTests.cs | 37 +- .../Runtime/ConversationEventKindsTests.cs | 82 -- .../ConversationObserverDispatcherTests.cs | 373 ------ .../Runtime/ConversationObserversTests.cs | 90 -- .../ConversationSessionApprovalTests.cs | 7 +- .../ConversationSessionBargeInTests.cs | 11 +- .../ConversationSessionCommittedCutTests.cs | 10 +- .../ConversationSessionCutTestSupport.cs | 7 +- .../Runtime/ConversationSessionCutTests.cs | 32 +- .../ConversationSessionCutWindowTests.cs | 17 +- .../ConversationSessionEditApprovalTests.cs | 124 ++ .../Runtime/ConversationSessionEditTests.cs | 74 +- .../ConversationSessionEmptyReplyTests.cs | 13 +- ...versationSessionFactoryModelFacingTests.cs | 2 +- .../ConversationSessionFactoryTests.cs | 4 +- .../ConversationSessionGraphCancelTests.cs | 18 +- .../ConversationSessionGraphFaultTests.cs | 17 +- .../ConversationSessionGraphIdTests.cs | 10 +- .../ConversationSessionGraphReplyTests.cs | 29 +- .../ConversationSessionInterruptionTests.cs | 83 +- .../ConversationSessionLifecycleTests.cs | 2 +- .../ConversationSessionModelFaultTests.cs | 19 +- ...onversationSessionParallelApprovalTests.cs | 250 ++++ .../Runtime/ConversationSessionPortTests.cs | 4 +- .../Runtime/ConversationSessionProbeTests.cs | 12 +- .../Runtime/ConversationSessionRecutTests.cs | 19 +- .../ConversationSessionReminderTests.cs | 6 +- .../ConversationSessionStreamingTests.cs | 6 +- .../Runtime/ConversationSessionTestSupport.cs | 2 +- .../ConversationSessionToolFailureTests.cs | 13 +- .../ConversationSessionTranscriptTests.cs | 2 +- .../ConversationSessionTurnHistoryTests.cs | 10 +- .../ConversationSessionTurnRunTests.cs | 16 +- .../ConversationSessionWorkspaceTests.cs | 5 +- .../ConversationSessionWriterOrderTests.cs | 4 +- .../Runtime/ConversationTurnAgentCutTests.cs | 13 +- .../Runtime/ConversationTurnAgentHarness.cs | 6 +- .../Runtime/ConversationTurnAgentSealTests.cs | 8 +- .../Runtime/ConversationTurnAgentTests.cs | 23 +- ...versationWorkspaceMarkerVisibilityTests.cs | 2 +- .../Runtime/ConversationWorkspaceTests.cs | 2 +- .../ConversationWorkspaceToolGraceTests.cs | 174 +++ .../Runtime/CutTurnBackgroundToolTests.cs | 253 ++++ .../Runtime/DelegatedToolsTests.cs | 14 +- .../Runtime/GraphConversationSessions.cs | 2 +- .../Runtime/GraphParticipantAgentTests.cs | 98 ++ .../Harness/BackgroundChildAgentTests.cs | 6 - .../Harness/ConversationFilesProviderTests.cs | 5 +- .../ConversationSessionBackgroundTests.cs | 4 +- .../ConversationSessionCatchUpReleaseTests.cs | 8 +- .../ConversationSessionDisposeRaceTests.cs | 2 +- .../ConversationSessionDroppedWriteTests.cs | 10 +- .../ConversationSessionGraphStateTests.cs | 6 +- .../ConversationSessionProviderStateTests.cs | 5 +- .../Harness/ConversationSessionShellTests.cs | 10 +- .../Harness/ConversationShellsTests.cs | 11 +- .../Runtime/HarnessTurn.cs | 3 +- .../Interruption/InterruptionFixture.cs | 7 +- .../Interruption/InterruptionSessions.cs | 13 +- .../PartiallyAnsweredToolFactory.cs | 16 +- ...dgeScopeThroughConversationSessionTests.cs | 47 +- .../Runtime/ModelFacingChatClientTests.cs | 2 +- .../Runtime/OuterRunAgent.cs | 40 + .../Runtime/RawEventAgent.cs | 1 + .../Runtime/RecordingTurnCompleter.cs | 5 +- .../Runtime/RunningToolInterruptTests.cs | 280 +++++ .../Runtime/RunningTurnSupersedeTests.cs | 116 ++ .../Runtime/RuntimeFakes.cs | 70 +- .../Runtime/ToolCancelOutsideTurnTests.cs | 109 ++ .../Runtime/Turn/TurnContextProviderTests.cs | 13 +- .../Runtime/Turn/TurnFilesTests.cs | 37 +- .../Runtime/Turn/TurnResultsTests.cs | 2 +- .../Runtime/Turn/TurnSourcesTests.cs | 40 +- .../Runtime/TurnFaultTelemetryTests.cs | 7 +- .../Runtime/TurnPipelineTests.cs | 98 +- .../Runtime/TurnProbeAgent.cs | 49 + .../ConversationSessionBackgroundIdleTests.cs | 15 +- .../ConversationSessionCrossEntryTests.cs | 12 +- .../ConversationSessionGetOrOpenTests.cs | 5 +- .../ConversationSessionIdleTimeoutTests.cs | 12 +- ...versationSessionWorkspaceHeartbeatTests.cs | 8 +- .../ConversationSessionWorkspaceStampTests.cs | 2 +- .../Sessions/ConversationSessionsFixture.cs | 9 +- .../Sessions/ConversationSessionsTests.cs | 2 +- .../Sessions/GatedSessionFactory.cs | 2 +- .../InMemoryConversationSessionsSweepTests.cs | 2 +- .../Sessions/RecordingConversationObserver.cs | 41 - .../State/StateDocumentTests.cs | 9 +- .../Tools/FilePublishBackgroundChildTests.cs | 11 +- .../Tools/FilePublishGraphParticipantTests.cs | 234 ++++ .../Tools/FilePublishToolDefinitionTests.cs | 3 +- .../Tools/MeetingFunction.cs | 22 + .../Tools/ToolCallScopeTests.cs | 2 +- .../Tools/TypedBindingToolTests.cs | 9 +- ...AgentCoreChatHistoryProviderCommitTests.cs | 42 +- ...AgentCoreChatHistoryProviderTestSupport.cs | 3 +- .../AgentCoreChatHistoryProviderTests.cs | 46 +- .../CompiledHistoryProviderTests.cs | 43 +- .../ConversationSessionCatchUpTests.cs | 17 +- .../ConversationSessionCompactionEditTests.cs | 26 +- ...nversationSessionCompactionFailureTests.cs | 13 +- ...onversationSessionCompactionTestSupport.cs | 11 +- .../ConversationSessionCompactionTests.cs | 6 +- ...ConversationSessionConversationRowTests.cs | 14 +- ...ConversationSessionGraphTranscriptTests.cs | 32 +- .../ConversationSessionOutsideAppendTests.cs | 7 +- .../ConversationSessionRefusedCatchUpTests.cs | 42 +- .../ConversationSessionResumeTestSupport.cs | 36 +- .../ConversationSessionResumeTests.cs | 6 +- .../ConversationSessionStateRestoreTests.cs | 62 +- .../ConversationSessionStoreFailureTests.cs | 180 +-- .../ConversationSessionTranscriptTests.cs | 37 +- .../ConversationTranscriptEditTests.cs | 4 +- .../Transcript/ConversationTranscriptTests.cs | 2 +- .../Transcript/FrontVoiceCommitTests.cs | 87 ++ .../Transcript/HeldFirstTurnChatClient.cs | 8 + .../Transcript/InnerAgentTranscriptTests.cs | 12 +- .../Transcript/RefusedTurnAccountingTests.cs | 147 +++ .../Transcript/TurnWordsTests.cs | 8 +- .../AgentCore.AspNetCore.Tests.csproj | 4 + .../Calls/PhoneCallAskTests.cs | 375 ++++++ .../Calls/PhoneCallEndedNoticeTests.cs | 165 +++ .../Calls/PhoneCallFrontVoiceTests.cs | 45 + .../Calls/PhoneCallTakeoverTests.cs | 70 ++ .../Calls/PhoneCallTests.cs | 385 ++++++ .../ConversationRegistrationTests.cs | 72 +- .../AddAgentCoreAuditTests.cs | 193 +++ .../AddAgentCoreHookTests.cs | 74 ++ .../DependencyInjection/AddAgentCoreTests.cs | 485 +------- .../DefaultSessionExpiryTests.cs | 2 +- .../DependencyInjection/StartedHostFixture.cs | 29 +- .../WorkspaceStartupTests.cs | 2 +- .../Endpoints/EntryGateFactsTests.cs | 173 +++ .../Endpoints/EntryGateFixture.cs | 91 ++ .../Endpoints/EntryGateTests.cs | 192 +++ .../Endpoints/EntrySelectorTests.cs | 220 ---- .../Endpoints/HttpSessionOwnerReuseTests.cs | 6 +- .../Endpoints/ResponseContinuationTests.cs | 2 +- .../Endpoints/ResponsesApprovalTests.cs | 32 + .../ResponsesBusyConversationTests.cs | 19 +- .../Endpoints/ResponsesDroppedWriteTests.cs | 2 +- .../ResponsesFilingAfterAbortTests.cs | 8 +- .../ResponsesParallelApprovalTests.cs | 281 +++++ .../ResponsesSharedSessionWaitTests.cs | 8 +- .../Endpoints/ResponsesSlowStoreTests.cs | 17 +- .../Endpoints/SourceWireTests.cs | 20 +- .../Fakes/ActingTimeProvider.cs | 18 + .../Fakes/AgentStateLog.cs | 3 +- .../Fakes/CallDecider.cs | 15 + .../Fakes/ClosingAuditSink.cs | 35 + .../Fakes/FailingCreateStore.cs | 35 + .../Fakes/FakeConversationInput.cs | 16 +- .../Fakes/FakeConversationOutput.cs | 3 +- .../Fakes/FakeSideband.cs | 169 +++ .../Fakes/FakeWebSocket.cs | 56 +- .../Fakes/GatedConversationSessions.cs | 83 ++ .../Fakes/GatedCreateStore.cs | 42 + .../Fakes/GatedStepChatClient.cs | 96 ++ .../Fakes/GatedToolCallingChatClient.cs | 10 + .../Fakes/InstructionsSeenChatClient.cs | 36 + .../Fakes/LatencyReadings.cs | 49 - .../Fakes/LatencyRecorder.cs | 27 + .../Fakes/MissingToolsChatClient.cs | 71 ++ .../Fakes/OpenAiLiveHost.cs | 175 +++ .../Fakes/PhoneCallHarness.cs | 100 ++ .../Fakes/RecordingAuditSink.cs | 35 + .../Fakes/RecordingLiveControl.cs | 38 + .../Fakes/RelayConnectionHarness.cs | 5 +- .../Fakes/RunningLiveCall.cs | 69 ++ .../Fakes/ScriptedVoicePort.cs | 4 +- .../Fakes/StallOnCueChatClient.cs | 37 +- .../Fakes/TelnyxRelayHost.cs | 49 +- .../Fakes/TestAuditSinkAdapter.cs | 19 + .../Fakes/UserStateLog.cs | 3 +- .../Fakes/VoiceLoopHarness.cs | 13 +- .../Hooks/HookRegistrationTests.cs | 265 +++++ .../AgentCoreAgentSessionStoreTests.cs | 3 +- .../ConversationSessionsRelayTests.cs | 21 +- .../OpenAiLive/CommentaryPiecesTests.cs | 33 + .../Vendors/OpenAiLive/Fixtures/p1-a.jsonl | 71 ++ .../OpenAiLive/Fixtures/p2-strict-0.jsonl | 39 + .../OpenAiLive/Fixtures/p3-drop-0.jsonl | 58 + .../OpenAiLive/Fixtures/p3-stale-0.jsonl | 58 + .../OpenAiLive/LiveEventReaderTests.cs | 47 + .../OpenAiLive/LiveInstructionsTests.cs | 30 + .../Vendors/OpenAiLive/LiveLog.cs | 36 + .../Vendors/OpenAiLive/LiveReplay.cs | 44 + .../Vendors/OpenAiLive/LiveSent.cs | 47 + .../OpenAiLive/LiveTranscriptLedgerTests.cs | 169 +++ .../OpenAiLive/OpenAiLiveCallEndTests.cs | 239 ++++ .../Vendors/OpenAiLive/OpenAiLiveCallTests.cs | 190 +++ .../OpenAiLive/OpenAiLiveCallsTests.cs | 88 ++ .../OpenAiLive/OpenAiLiveClockTests.cs | 66 ++ .../OpenAiLive/OpenAiLiveCredentialsTests.cs | 43 + .../OpenAiLive/OpenAiLiveEndToEndTests.cs | 92 ++ .../OpenAiLive/OpenAiLiveFrontVoiceTests.cs | 119 ++ .../OpenAiLive/OpenAiLiveSettingsTests.cs | 43 + .../OpenAiLiveWebhookCleanupTests.cs | 135 +++ .../OpenAiLive/OpenAiLiveWebhookTests.cs | 187 +++ .../Vendors/OpenAiLive/OpenAiLiveWireTests.cs | 56 + .../StandardWebhookSignatureTests.cs | 86 ++ .../OpenAiLive/WebSocketSidebandTests.cs | 45 + .../TelnyxRelayBargeInGuardTests.cs | 4 +- .../TelnyxRelayBargeInRecoveryTests.cs | 13 +- .../TelnyxRelayBargeInTestSupport.cs | 2 +- .../TelnyxRelay/TelnyxRelayCallGateTests.cs | 200 ++++ .../TelnyxRelayConnectionFaultTests.cs | 6 +- .../TelnyxRelayConversationEndTests.cs | 93 +- .../TelnyxRelayFinalPromptCutTests.cs | 94 +- .../TelnyxRelayFrameReaderTests.cs | 20 +- .../TelnyxRelay/TelnyxRelayInputTests.cs | 5 +- .../TelnyxRelay/TelnyxRelayLineSpokenTests.cs | 194 +++ .../TelnyxRelayOutputBargeTests.cs | 8 +- .../TelnyxRelayOutputQueueTests.cs | 4 +- .../TelnyxRelay/TelnyxRelayTakeoverTests.cs | 66 ++ .../TelnyxRelay/TelnyxRelayTurnTests.cs | 38 +- .../Voice/AsyncEventTests.cs | 2 +- .../Voice/ConversationChannelShapeTests.cs | 3 +- .../Voice/ConversationChannelTests.cs | 3 +- .../Voice/ConversationInputPortTests.cs | 2 +- .../Voice/ConversationRouteSelectionTests.cs | 10 +- .../Voice/ConversationSpeechPairingTests.cs | 3 +- .../Voice/ConversationTurnLatencySuite.cs | 14 - .../Voice/FillerSchedulerTests.cs | 4 +- .../Voice/FinalTranscriptInterruptTests.cs | 65 +- .../Voice/PipelineReplyEngineTurnTests.cs | 16 +- .../Voice/PipelineReplyFillerTests.cs | 7 +- .../Voice/PipelineReplyTests.cs | 15 +- .../Voice/SpeechHandleTests.cs | 3 +- .../Voice/SpeechQueueTests.cs | 2 +- .../Voice/SpeechSchedulerInterruptTests.cs | 2 +- .../Voice/SpeechSchedulerTests.cs | 2 +- .../Voice/TaskTeardownTests.cs | 2 +- .../Voice/TextForwardingTests.cs | 3 +- .../Voice/ToolFillerScopeTests.cs | 6 +- .../Voice/TurnMetricsTests.cs | 61 +- .../Voice/UserTurnHandlerTests.cs | 17 +- .../Voice/VoiceActivityBargeTargetTests.cs | 8 +- .../Voice/VoiceActivityHooksTests.cs | 59 + .../Voice/VoiceConversationLoopTests.cs | 166 ++- .../Voice/VoiceNoticesTests.cs | 39 + .../Voice/VoiceOptionsBinderTests.cs | 6 +- .../Voice/VoiceSessionAwayCloseTests.cs | 2 +- .../Voice/VoiceSessionAwayTests.cs | 8 +- .../Voice/VoiceSessionCloseTests.cs | 3 +- .../Voice/VoiceSessionTests.cs | 8 +- .../Audit/AuditEventValidationTests.cs | 2 +- .../Audit/AuditHashTests.cs | 2 +- .../AgentCoreHostRouteTests.cs | 41 +- .../AgentCoreHostTests.cs | 81 +- .../Audit/Postgres/PostgresAuditSinkTests.cs | 2 +- .../Postgres/ConversationThreadSchemaTests.cs | 2 +- .../Postgres/PostgresConversationEndTests.cs | 170 +++ .../PostgresConversationStoreTests.cs | 2 +- .../PostgresConversationStoreTruncateTests.cs | 4 +- .../PostgresConversationStoreWindowTests.cs | 2 +- .../PostgresConversationVerificationTests.cs | 46 +- .../Postgres/PostgresLostAppendTests.cs | 3 +- .../Postgres/PostgresTurnVerificationTests.cs | 10 +- .../Database/Postgres/PostgresSchemaTests.cs | 2 +- .../Qdrant/AmbiguityIntegrationTests.cs | 88 +- .../ConversationStoreStateFacts.cs | 70 ++ .../ConversationStoreSummaryRowFacts.cs | 2 +- .../DelegatingConversationStore.cs | 7 + .../AgentCore.TestSupport/FakeTimeProvider.cs | 33 +- tests/AgentCore.TestSupport/GatedTool.cs | 48 + tests/AgentCore.TestSupport/RecordingHook.cs | 106 ++ .../TwoGatedCallsChatClient.cs | 59 + 789 files changed, 32663 insertions(+), 9132 deletions(-) delete mode 100644 src/AgentCore.Application/Audit/AuditConversationObserver.cs create mode 100644 src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs delete mode 100644 src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs delete mode 100644 src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs create mode 100644 src/AgentCore.Application/Hooks/AgentHook.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs create mode 100644 src/AgentCore.Application/Hooks/BuiltInHooks.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/Delivery.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/GateRunner.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/HookLog.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/HookReader.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/HookRuntime.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/HookScopes.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/HookTable.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/Mailbox.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/NoticeHub.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/SessionHooks.cs create mode 100644 src/AgentCore.Application/Hooks/Engine/TurnRounds.cs create mode 100644 src/AgentCore.Application/Hooks/GatePoint.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/CallGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/CallRefusal.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/CompactionGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/EntryGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/EntryTransport.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ModelGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/RunEndGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/RunGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ToolGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs create mode 100644 src/AgentCore.Application/Hooks/Gates/TurnGate.cs create mode 100644 src/AgentCore.Application/Hooks/HookFailure.cs create mode 100644 src/AgentCore.Application/Hooks/HookGate.cs create mode 100644 src/AgentCore.Application/Hooks/HookNotice.cs create mode 100644 src/AgentCore.Application/Hooks/HookScope.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/CallGateChain.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/ModelRound.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs create mode 100644 src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ApprovalState.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/CallEnd.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/CallEndReason.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/CallEnded.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/CallStarted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/Compacted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/Fault.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/FaultKind.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/FilePublished.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/HostStarted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/HostStopping.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/InputModerated.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/InputVerdict.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/LineSpoken.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ModelCalled.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ReplyCut.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/Speaker.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/StageChanged.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ToolCalled.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnLatency.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnRefused.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnStarted.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/UnloadCause.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/VoiceParty.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/VoiceState.cs create mode 100644 src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs delete mode 100644 src/AgentCore.Application/Ports/IConversationObserver.cs delete mode 100644 src/AgentCore.Application/Ports/ISourcePort.cs rename src/AgentCore.Application/Runtime/{ => Agents}/ConversationTurnRunner.cs (75%) rename src/AgentCore.Application/Runtime/{ => Agents}/DelegatedAgentRun.cs (71%) rename src/AgentCore.Application/Runtime/{ => Agents}/FallbackAgent.cs (94%) rename src/AgentCore.Application/Runtime/{ => Agents/Graph}/GraphFaultAgent.cs (98%) create mode 100644 src/AgentCore.Application/Runtime/Agents/Graph/GraphParticipantAgent.cs rename src/AgentCore.Application/Runtime/{ => Agents/Graph}/GraphStateAgent.cs (93%) rename src/AgentCore.Application/Runtime/{ => Agents}/ModerationAgent.cs (77%) rename src/AgentCore.Application/Runtime/{ => Agents}/RequireOutputAgent.cs (89%) rename src/AgentCore.Application/Runtime/{ => Clarification}/ClarificationText.cs (94%) rename src/AgentCore.Application/Runtime/{ => Clarification}/Clarifications.cs (92%) delete mode 100644 src/AgentCore.Application/Runtime/ConversationEvent.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationEventChain.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationEventKind.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationEventKinds.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationEventPayloadKeys.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationObserverDispatcher.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationObservers.cs delete mode 100644 src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs rename src/AgentCore.Application/Runtime/{ => Cut}/ConversationCutTracker.cs (87%) rename src/AgentCore.Application/Runtime/{ => Cut}/TurnCut.cs (90%) rename src/AgentCore.Application/Runtime/{ => Cut}/TurnCutSlot.cs (97%) create mode 100644 src/AgentCore.Application/Runtime/Harness/ApprovalAnswerHold.cs create mode 100644 src/AgentCore.Application/Runtime/Harness/ApprovalQueueDrain.cs rename src/AgentCore.Application/Runtime/{ => Session}/AgentCoreAgentSession.cs (97%) rename src/AgentCore.Application/Runtime/{ => Session}/ConversationBusyMark.cs (93%) create mode 100644 src/AgentCore.Application/Runtime/Session/ConversationCleanup.cs create mode 100644 src/AgentCore.Application/Runtime/Session/ConversationEnding.cs rename src/AgentCore.Application/Runtime/{ => Session}/ConversationSession.cs (81%) rename src/AgentCore.Application/Runtime/{ => Session}/ConversationSessionFactory.cs (55%) create mode 100644 src/AgentCore.Application/Runtime/Session/ConversationSessionLifetime.cs rename src/AgentCore.Application/Runtime/{ => Session}/ConversationSessionSeams.cs (79%) rename src/AgentCore.Application/Runtime/{ => Session}/ConversationSessionStateStore.cs (88%) rename src/AgentCore.Application/Runtime/{ => Session}/ConversationStoreReads.cs (85%) rename src/AgentCore.Application/Runtime/{ => Session}/ConversationTranscriptLedger.cs (86%) rename src/AgentCore.Application/Runtime/{ => Session}/ConversationWorkspace.cs (99%) create mode 100644 src/AgentCore.Application/Runtime/Session/SessionFaults.cs rename src/AgentCore.Application/Runtime/{ => ToolCalls}/AuditingFunctionInvokingChatClient.cs (52%) create mode 100644 src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs create mode 100644 src/AgentCore.Application/Runtime/ToolCalls/FinishedToolPairs.cs rename src/AgentCore.Application/Runtime/{ => ToolCalls}/ModelFacingChatClient.cs (96%) create mode 100644 src/AgentCore.Application/Runtime/ToolCalls/ReturnedToolCalls.cs rename src/AgentCore.Application/Runtime/{ => ToolCalls}/ToolCallScopes.cs (87%) rename src/AgentCore.Application/Runtime/{ => ToolCalls}/ToolFaultMark.cs (94%) create mode 100644 src/AgentCore.Application/Runtime/ToolCalls/ToolRun.cs rename src/AgentCore.Application/Runtime/{ => Turn}/ConversationTurn.cs (63%) rename src/AgentCore.Application/Runtime/{ => Turn}/ConversationTurnOrigin.cs (94%) create mode 100644 src/AgentCore.Application/Runtime/Turn/FileNotice.cs rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ConversationTurnAgent.cs (81%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ConversationTurnCommit.cs (54%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ConversationTurnCompletion.cs (70%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ConversationTurnStream.cs (84%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ConversationTurnWriters.cs (93%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ITurnCompleter.cs (96%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ReplyOutcome.cs (84%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/ReplyText.cs (96%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/TurnDisposition.cs (82%) create mode 100644 src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnFailureReasons.cs create mode 100644 src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnOutcomes.cs create mode 100644 src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRefusals.cs rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/TurnReplyResolution.cs (72%) rename src/AgentCore.Application/Runtime/{ => Turn/Lifecycle}/TurnRun.cs (96%) delete mode 100644 src/AgentCore.Application/Runtime/Turn/OuterCall.cs create mode 100644 src/AgentCore.Application/Runtime/Turn/TurnApprovalAnswers.cs rename src/AgentCore.Application/Runtime/{ => Turn}/TurnInvocation.cs (77%) delete mode 100644 src/AgentCore.Application/Runtime/TurnRefusals.cs create mode 100644 src/AgentCore.Application/Transcript/FrontVoice.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallAnswer.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallAsk.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallAsks.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallConversationLostException.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallEndNotice.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallLog.cs create mode 100644 src/AgentCore.AspNetCore/Calls/PhoneCall.cs create mode 100644 src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs create mode 100644 src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs create mode 100644 src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs create mode 100644 src/AgentCore.AspNetCore/DependencyInjection/Startup/HookStartup.cs delete mode 100644 src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs delete mode 100644 src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs delete mode 100644 src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs delete mode 100644 src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/CommentaryPieces.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveLine.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTranscriptLedger.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCredentials.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/AcceptOutcome.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/LiveIncomingCall.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/StandardWebhookSignature.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/ILiveSideband.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveAttach.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/WebSocketSideband.cs delete mode 100644 src/AgentCore.AspNetCore/Vendors/Telnyx/.gitkeep rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Connection}/OutboundItem.cs (88%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Connection}/TelnyxRelayConnection.cs (84%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Connection}/TelnyxRelayConversationChannelFactory.cs (57%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Connection}/TelnyxRelayInput.cs (93%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Connection}/TelnyxRelayOutput.cs (96%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Wire}/RelayProtocolException.cs (90%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Wire}/TelnyxRelayContracts.cs (74%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Wire}/TelnyxRelayFrameReader.cs (64%) rename src/AgentCore.AspNetCore/Vendors/TelnyxRelay/{ => Wire}/TelnyxRelayJson.cs (57%) delete mode 100644 src/AgentCore.AspNetCore/Voice/ConversationChannel.cs rename src/AgentCore.AspNetCore/Voice/{ => Diagnostics}/ConversationRouteLog.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Diagnostics}/VoiceConversationLog.cs (86%) rename src/AgentCore.AspNetCore/Voice/{ => Filler}/FillerOptions.cs (82%) rename src/AgentCore.AspNetCore/Voice/{ => Filler}/FillerScheduler.cs (96%) rename src/AgentCore.AspNetCore/Voice/{ => Filler}/FillerSource.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Filler}/ToolFillerScope.cs (84%) rename src/AgentCore.AspNetCore/Voice/{ => Options}/BoundedTimerLimits.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Options}/VoiceOptions.cs (89%) rename src/AgentCore.AspNetCore/Voice/{ => Options}/VoiceOptionsBinder.cs (97%) create mode 100644 src/AgentCore.AspNetCore/Voice/Ports/CallTraits.cs rename src/AgentCore.AspNetCore/Voice/{ => Ports}/ConversationInput.cs (77%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/FrameOutcome.cs (92%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/FrameParser.cs (88%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/IConversationAdapter.cs (67%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/IConversationInputPort.cs (90%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/IConversationOutputPort.cs (96%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/IConversationTransportAdapter.cs (91%) rename src/AgentCore.AspNetCore/Voice/{ => Ports}/ISpeechAdapter.cs (84%) create mode 100644 src/AgentCore.AspNetCore/Voice/Routing/ConversationChannel.cs rename src/AgentCore.AspNetCore/Voice/{ => Routing}/ConversationChannelContext.cs (90%) rename src/AgentCore.AspNetCore/Voice/{ => Routing}/ConversationEndpointRouteBuilderExtensions.cs (91%) rename src/AgentCore.AspNetCore/Voice/{ => Routing}/ConversationSeams.cs (90%) rename src/AgentCore.AspNetCore/Voice/{ => Routing}/IConversationChannelFactory.cs (92%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/AgentState.cs (95%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/AgentStateChanged.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/UserAwayOptions.cs (86%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/UserAwayTimer.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/UserState.cs (89%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/UserStateChanged.cs (94%) create mode 100644 src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs rename src/AgentCore.AspNetCore/Voice/{ => Session}/VoiceSession.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Session}/VoiceSessionClosedException.cs (93%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/BackgroundSpeeches.cs (90%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/EngineReplyStream.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/PipelineReply.cs (95%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/ReplyEvent.cs (90%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/ReplyEventKind.cs (86%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/ReplyHearing.cs (59%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/SayReply.cs (92%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/TextForwarding.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/TextForwardingResult.cs (93%) rename src/AgentCore.AspNetCore/Voice/{ => Speech/Replies}/TextPlayback.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/SpeechDoneCallbacks.cs (91%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/SpeechHandle.cs (98%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/SpeechPriority.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/SpeechQueue.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/SpeechScheduler.cs (98%) rename src/AgentCore.AspNetCore/Voice/{ => Speech}/SpeechTasks.cs (98%) rename src/AgentCore.AspNetCore/Voice/{ => Threading}/AsyncEvent.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Threading}/TaskTeardown.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Transport}/ConnectionTaskObserver.cs (98%) rename src/AgentCore.AspNetCore/Voice/{ => Transport}/JsonWebSocketPump.cs (95%) rename src/AgentCore.AspNetCore/Voice/{ => Transport}/JsonWebSocketPumpOptions.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Transport}/JsonWebSocketSender.cs (99%) rename src/AgentCore.AspNetCore/Voice/{ => Turns}/ConversationSpeechPairing.cs (97%) rename src/AgentCore.AspNetCore/Voice/{ => Turns}/InterruptionSource.cs (94%) rename src/AgentCore.AspNetCore/Voice/{ => Turns}/TurnMetrics.cs (67%) rename src/AgentCore.AspNetCore/Voice/{ => Turns}/UserTurnHandler.cs (93%) rename src/AgentCore.AspNetCore/Voice/{ => Turns}/VoiceActivity.cs (74%) create mode 100644 src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs delete mode 100644 src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs delete mode 100644 src/AgentCore.Infrastructure/Telephony/Telnyx/.gitkeep delete mode 100644 tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs create mode 100644 tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderKnowledgeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderParsingTests.cs create mode 100644 tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderSpokenLineTests.cs create mode 100644 tests/AgentCore.Application.Tests/Configuration/ExampleDocumentAgentsJson.cs create mode 100644 tests/AgentCore.Application.Tests/Configuration/ExampleDocumentStateJson.cs create mode 100644 tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreStateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryHarness.cs delete mode 100644 tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs delete mode 100644 tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs create mode 100644 tests/AgentCore.Application.Tests/Fakes/NewestWordsToolChatClient.cs create mode 100644 tests/AgentCore.Application.Tests/Fakes/ParallelToolChatClient.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ApprovalDenialRoundsTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/BeforeGates.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/BuiltInHooksTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/CallEndNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/CallGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ConversationEndNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ConversationEndReloadTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/CutAndFaultNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/DecidingHook.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/GateOverheadSuite.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/GraphParticipantHookTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/HookSessions.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/LoggingHookTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ModelCalledNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ModelFailureGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ModerationNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/NoticeHubLifetimeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/NoticeOrderTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/Recorder.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/RequestsSeen.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/SkillLoadedNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/SubagentBracketTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TelemetryHookSuite.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/ToolHookStackingTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TurnNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TurnOutcomesTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TurnRefusalTokensTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/TurnRefusedNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/UnloadNoticeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/UsageModel.cs create mode 100644 tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs delete mode 100644 tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs delete mode 100644 tests/AgentCore.Application.Tests/Runtime/ConversationObserverDispatcherTests.cs delete mode 100644 tests/AgentCore.Application.Tests/Runtime/ConversationObserversTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceToolGraceTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/OuterRunAgent.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/RunningTurnSupersedeTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs create mode 100644 tests/AgentCore.Application.Tests/Runtime/TurnProbeAgent.cs delete mode 100644 tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs create mode 100644 tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs create mode 100644 tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs create mode 100644 tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs create mode 100644 tests/AgentCore.Application.Tests/Transcript/RefusedTurnAccountingTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreAuditTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreHookTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFixture.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateTests.cs delete mode 100644 tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/ActingTimeProvider.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/CallDecider.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/ClosingAuditSink.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/FailingCreateStore.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/GatedCreateStore.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/GatedStepChatClient.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/InstructionsSeenChatClient.cs delete mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/MissingToolsChatClient.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/RecordingLiveControl.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/TestAuditSinkAdapter.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/CommentaryPiecesTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p1-a.jsonl create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p2-strict-0.jsonl create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-drop-0.jsonl create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-stale-0.jsonl create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveLog.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveReplay.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCredentialsTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTakeoverTests.cs delete mode 100644 tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Voice/VoiceNoticesTests.cs create mode 100644 tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationEndTests.cs create mode 100644 tests/AgentCore.TestSupport/ConversationStoreStateFacts.cs create mode 100644 tests/AgentCore.TestSupport/GatedTool.cs create mode 100644 tests/AgentCore.TestSupport/RecordingHook.cs create mode 100644 tests/AgentCore.TestSupport/TwoGatedCallsChatClient.cs diff --git a/.claude/rules/voice-components.md b/.claude/rules/voice-components.md index 96c136b6..ab92c6cb 100644 --- a/.claude/rules/voice-components.md +++ b/.claude/rules/voice-components.md @@ -35,6 +35,7 @@ flowchart TB Loop["VoiceConversationLoop"] UTH["UserTurnHandler"] Act["VoiceActivity"] + PhoneCall["Calls/PhoneCall
G1, CallStarted, LineSpoken, end, CallEnded"] end subgraph Speech["Speech control"] @@ -47,6 +48,7 @@ flowchart TB Hear["ReplyHearing
what caller heard"] Fill["ToolFillerScope
→ FillerScheduler"] Met["TurnMetrics"] + VN["VoiceNotices
state → VoiceStateChanged"] end subgraph Engine["AgentCore.Application"] @@ -60,16 +62,21 @@ flowchart TB Conn --> Pump & Sender & Loop Pump --> Reader --> In In -->|"ConversationInput:
Started / Utterance / Barge"| Loop - Loop -->|GetOrOpen / Close| Sessions --> CS + Loop -->|Admit / Start / End / Close| PhoneCall -->|GetOrOpen / Close| Sessions --> CS + PhoneCall -.->|"replaced: a newer socket took the call id over"| Loop -.->|"cancel, close as replaced"| Conn Loop -->|interim / final| UTH Loop -->|Barge| Act Loop -->|Barge: StopAsync| Out + Act -->|each reply's ReplyHearing| Loop + Loop -->|"line chain: HeardAsync → LineSpoken"| PhoneCall UTH -->|TryGenerateReply| Act UTH --> VS Act --> Stream Act --> Pipe Act -->|schedule| Sched VS --> Sched + VS -->|AgentStateChanged / UserStateChanged| VN + VN -->|Hooks.Raise| CS VS -->|Say| Say Sched -->|authorize| Handle Pipe --> Handle @@ -79,14 +86,14 @@ flowchart TB Say --> Fwd Pipe --> Hear -->|TurnCut| Stream Pipe --> Fill -->|Say| VS - Pipe --> Met + Pipe --> Met -->|TurnLatency notice| CS 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 Loop,UTH,Act,PhoneCall turn + class VS,Sched,Handle,Pipe,Say,Fwd,Hear,Fill,Met,VN speech class Stream,Sessions,CS engine ``` @@ -105,16 +112,18 @@ sequenceDiagram T->>P: setup frame P->>L: Started(conversationId) - L->>L: GetOrOpen session, make VoiceActivity + UserTurnHandler + L->>L: PhoneCall.AdmitAsync (G1) + StartAsync, 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->>L: queue the caller line behind any held agent line L->>U: OnFinalTranscript - U->>A: InterruptByFinalTranscript (cut old speech) + U->>A: InterruptByFinalTranscript (cut old speech; earlier replies' barge windows wait HeardTextWait, then close) U->>A: TryGenerateReply(text) A->>E: Start engine turn + A->>L: reply's ReplyHearing → held agent line queued A->>S: ScheduleSpeech(handle) S->>R: authorize speech R->>R: wait for user silence @@ -128,5 +137,6 @@ sequenceDiagram end end R->>S: speech done → agent Listening, away timer armed + Note over L,R: The held agent line is raised (PhoneCall.HeardAsync, the reply's turn) with ReplyHearing.HeardText once
the speech is done and its barge window closed: a barge report for it; the next final prompt, once the report
that may follow it had its HeardTextWait (a later report changes nothing, and is logged); or DrainAsync, at once
(an interrupted reply settled its cut already, so its window closes when its speech is done) 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/config/example.yaml b/config/example.yaml index 5882b621..9ebb473d 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -21,7 +21,7 @@ # 5. tools what an agent may call # 6. agents models, prompts, knowledge # 7. entries what a caller reaches: a stage machine or one agent -# 8. providers adapters: llm, conversation, speech, telephony, moderation, embeddings, knowledge, +# 8. providers adapters: llm, conversation, speech, moderation, embeddings, knowledge, # telemetry # 9. evaluation the judge that scores finished turns @@ -360,16 +360,13 @@ providers: - { kind: openai, model: gpt-4.1, as: judge } # evaluation only, chosen on judgement - { kind: openai, model: gpt-4.1-nano, as: cheap, webSearch: false } - # Three voice planes. They are not duplicates. + # Two voice planes. They are not duplicates. # conversation the media pipe. It carries the audio and owns the route /v1/{entry}/call. # speech recognition (stt) and synthesis (tts), one role at a time. - # telephony call control: dial, transfer, hang up. It must outlive any one media leg, - # because a warm transfer creates a second leg. conversation: { kind: telnyx-relay } speech: stt: { kind: telnyx-relay } # bundled with the relay, so it matches conversation tts: { kind: telnyx-relay } # bundled with the relay, so it matches conversation - telephony: { kind: telnyx } # Reads what the CALLER said, before the model runs. A flagged turn never reaches the model and # speaks refusalReply instead. Reads the openai-api-key secret, the same one llm reads. diff --git a/src/AgentCore.Application/Audit/AuditConversationObserver.cs b/src/AgentCore.Application/Audit/AuditConversationObserver.cs deleted file mode 100644 index ec9dc335..00000000 --- a/src/AgentCore.Application/Audit/AuditConversationObserver.cs +++ /dev/null @@ -1,59 +0,0 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; - -namespace AgentCore.Application.Audit -{ - /// - /// Turns the durable facts of a call into the append-only chain of D23. - /// - internal sealed class AuditConversationObserver : IConversationObserver - { - private readonly IAuditSinkPort _sink; - - /// Creates the observer that writes the chain. - /// Where the rows go. - /// is . - public AuditConversationObserver(IAuditSinkPort sink) - { - ArgumentNullException.ThrowIfNull(sink); - - _sink = sink; - } - - /// - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(conversationEvent); - - // A diagnostic-only fact took no identity, so there is no row to write and nothing to report. - if (conversationEvent.EventId is not { } eventId) - { - return ValueTask.CompletedTask; - } - - // The two enums are not interchangeable, and a value outside the closed set is dropped rather - // than thrown over: the turn loop pairs an identity with a stored kind, so this is unreachable - // for any event the session raises, and audit is a record of the conversation and never a part of it. - if (!ConversationEventKinds.TryGetAuditKind(conversationEvent.Kind, out AuditEventKind kind)) - { - return ValueTask.CompletedTask; - } - - AuditEvent auditEvent = new() - { - ConversationId = conversationEvent.ConversationId, - EventId = eventId, - Kind = kind, - OccurredAt = conversationEvent.OccurredAt, - TurnIndex = conversationEvent.TurnIndex, - AmendsEventId = conversationEvent.AmendsEventId, - Payload = conversationEvent.Payload, - }; - - // The token of the run belongs to the dispatcher, which passes CancellationToken.None: the - // enqueue belongs to the record of the conversation, not to a turn the caller may have cancelled. - return _sink.AppendAsync(auditEvent, cancellationToken); - } - } -} diff --git a/src/AgentCore.Application/Audit/QueuedAuditSink.cs b/src/AgentCore.Application/Audit/QueuedAuditSink.cs index 49a692d9..50dbf514 100644 --- a/src/AgentCore.Application/Audit/QueuedAuditSink.cs +++ b/src/AgentCore.Application/Audit/QueuedAuditSink.cs @@ -15,7 +15,7 @@ namespace AgentCore.Application.Audit /// public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposable { - /// The number of events the queue holds before it starts dropping. + /// The number of events the queue holds before an append waits. public const int DefaultCapacity = 10_000; /// The most events the writer hands the store in one conversation. @@ -60,7 +60,7 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa /// . The library never throws for want of one. /// /// - /// The number of events the queue holds before it drops, or . + /// The number of events the queue holds before an append waits, or . /// /// /// The most events one call to the store carries, or . @@ -101,26 +101,32 @@ public QueuedAuditSink( _writer = Task.Run(WriteAsync, CancellationToken.None); } + /// + /// Gets or sets what waits for before it waits for the queue: the delivery of every + /// notice already raised to the audit hook. The ASP.NET boot sets it. + /// + internal Func? Upstream { get; set; } + /// - public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) + public async ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(auditEvent); // Counted before the write, so FlushAsync never reads zero while an event is in the channel. _ = Interlocked.Increment(ref _pending); - if (_queue.Writer.TryWrite(auditEvent)) + try { - return ValueTask.CompletedTask; + // A full queue makes any caller wait. Behind the audit hook the caller is the hook's own reader, off + // the turn; code that calls this directly waits on its own thread until the writer makes room. + await _queue.Writer.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is ChannelClosedException or OperationCanceledException) + { + // The sink is disposed, or the caller gave up: nothing will take the row, so it is reported. + _ = Interlocked.Decrement(ref _pending); + Report(auditEvent); } - - // Either the queue is full or the sink is disposed. Both are a drop, and both are reported: - // the caller is a turn loop, and a turn is never told that its record was lost. - _ = Interlocked.Decrement(ref _pending); - - Report(auditEvent); - - return ValueTask.CompletedTask; } /// Waits until every accepted event has been handed to the store. @@ -128,6 +134,11 @@ public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellati /// A task that completes when nothing is left in the queue. public async ValueTask FlushAsync(CancellationToken cancellationToken = default) { + if (Upstream is { } upstream) + { + await upstream().WaitAsync(cancellationToken).ConfigureAwait(false); + } + while (Volatile.Read(ref _pending) > 0) { cancellationToken.ThrowIfCancellationRequested(); @@ -249,7 +260,7 @@ await _retry.ExecuteAsync( } /// Writes the one line a dropped event is worth, and never throws. - /// The event the queue had no room for. + /// The event nothing will take: the queue was closed, or the caller gave up. private void Report(AuditEvent auditEvent) { try diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs b/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs index b3e31849..196f03f4 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs @@ -1,4 +1,7 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Layers; using AgentCore.Application.Runtime.Harness; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -47,45 +50,71 @@ public static bool Matches(string pattern, string toolName) /// The agents.defaults section, or . /// The agent being compiled. /// The tools the agent offers the model, if any. + /// The hooks of the compile. /// - /// The pending queue and the standing-rule keys when the agent may surface an approval — - /// through auto:, which adds the approval layer, or through an approval-required tool — - /// else empty. An absent key is never kept, so both ride together. + /// The pending queue, the standing-rule and the held-answer keys when the agent may surface an approval — + /// through auto:, which adds the approval layer, through an approval-required tool, or through a + /// BeforeRun hook, which can add one at run time — else empty. An absent key is never kept, so all three ride together. /// public static IReadOnlyList StateKeysFor( - AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools) + AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools, HookRuntime hooks) { ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(hooks); - return Compose(defaults, item).Count == 0 - && tools?.OfType().Any() != true + return !MayAsk(defaults, item, tools, hooks) ? [] - : [PendingApprovalQueue.PendingStateKey, PendingApprovalQueue.StandingStateKey]; + : [PendingApprovalQueue.PendingStateKey, PendingApprovalQueue.StandingStateKey, PendingApprovalQueue.HeldStateKey]; } /// - /// Bakes one agent's auto: into its pipeline as UseToolApproval rules. No - /// patterns, no layer — the document pays nothing for approval it never declares. + /// Bakes one agent's auto: and the BeforeToolApproval hooks into its pipeline as one UseToolApproval rule, + /// with the deny layer outside it when a hook overrides BeforeToolApproval, directly outside + /// it, and innermost whenever the agent can ask. An agent that can ask nothing gets no layer — the document pays nothing for + /// approval it never declares. /// /// The compiled agent. /// The agents.defaults section, or . /// The agent being compiled. + /// The tools the agent offers the model, if any. + /// The hooks of the compile. /// The approval-wrapped agent, or unchanged. - public static AIAgent Apply(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item) + public static AIAgent Apply( + AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools, HookRuntime hooks) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(hooks); + + IReadOnlyList auto = Compose(defaults, item); + bool gated = hooks.Gates.Overrides(GatePoint.BeforeToolApproval); + if (!gated && !MayAsk(defaults, item, tools, hooks)) + { + return agent; + } + + AIAgent held = ApprovalAnswerHold.Use(new AIAgentBuilder(agent)).Build(); + if (auto.Count == 0 && !gated) + { + return held; + } + + AIAgentBuilder builder = new(held); + if (gated) + { + builder = ApprovalGateLayer.UseDenials(builder); + } - return Compose(defaults, item) is not { Count: > 0 } auto - ? agent - : new AIAgentBuilder(agent) - .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [.. auto.Select(ToRule)] }) + return ApprovalQueueDrain.Use(builder) + .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [ApprovalGateLayer.Rule(auto, hooks)] }) .Build(); } - private static Func> ToRule(string pattern) + private static bool MayAsk(AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools, HookRuntime hooks) { - return context => ValueTask.FromResult(Matches(pattern, context.FunctionCallContent.Name)); + return Compose(defaults, item).Count > 0 + || tools?.OfType().Any() == true + || hooks.Gates.Overrides(GatePoint.BeforeRun); } } } diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs b/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs index 162c068c..e636c0ae 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs @@ -1,6 +1,7 @@ using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Evaluation; +using AgentCore.Application.Hooks; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; using AgentCore.Application.Secrets; @@ -46,7 +47,7 @@ public AgentCompilationContext(IChatClientFactory chatClients) public PromptModerator? Moderation { get; init; } /// - /// Gets or sets the backing store of store 1, or for memory. + /// Gets or sets the backing store of the conversation history, or for memory. /// public IConversationStore? ConversationStore { get; init; } @@ -72,8 +73,9 @@ public AgentCompilationContext(IChatClientFactory chatClients) public ILoggerFactory? Loggers { get; init; } /// - /// Gets or sets the clock a clock: agent reads the date from, or - /// for . + /// Gets or sets the compile's clock, or for . A + /// clock: agent reads the date from it, and the hooks' gate deadlines, notice timeouts and idle + /// eviction run on its timers. /// public TimeProvider? Clock { get; init; } @@ -89,6 +91,13 @@ public AgentCompilationContext(IChatClientFactory chatClients) /// public ResolvedSecrets? Secrets { get; init; } + /// + /// Gets or sets the hooks every compiled agent runs, AgentCore's built-ins first, or + /// for none. Gates compile into the agents from this list; a hook passed only to a + /// hears notices but runs no gate. + /// + public IReadOnlyList? Hooks { get; init; } + /// /// Gets or sets the compaction stages every agent runs. /// diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs index 987f6315..cbfcd0f9 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs @@ -1,5 +1,7 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Layers; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; using AgentCore.Application.Runtime.Compaction; @@ -24,7 +26,7 @@ internal static class AgentContextProviderCompiler /// The compile-time seams. /// This agent's JSON pointer. /// Resolves an agents.items id to its compiled agent, or when undeclared. - /// Store 1, or on a row whose session carries no history and so has nothing to summarise. + /// The conversation history, or on a row whose session carries no history and so has nothing to summarise. public static List Build( AgentsWalk walk, AgentConfiguration item, @@ -61,10 +63,15 @@ public static List Build( providers.Add(new ReaderContentFilterProvider()); CompactionStages stages = context.Compaction ?? CompactionStrategyFactory.Create(context.ChatClients.GetChatClient(model), contextWindow); - providers.AddRange(CompactionStrategyFactory.BuildProviders(stages, history)); + providers.AddRange(CompactionStrategyFactory.BuildProviders(stages, history, walk.Hooks)); AgentHarnessProviders.Add(providers, defaults, item, context, pointer, resolve, walk.Background); + if (walk.Hooks.Gates.Overrides(GatePoint.BeforeRun)) + { + providers.Add(new RunGateProvider(walk.Hooks)); + } + if (AgentKnowledge.Compose(defaults, item) is not { } composed) { return providers; diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs index b381ce9c..3a570f8a 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs @@ -1,10 +1,11 @@ using System.Text.Json; using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Configuration.Compilation { diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs b/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs index 34f89702..e34f4292 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs @@ -1,8 +1,12 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Layers; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Runtime.Turn; using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; namespace AgentCore.Application.Configuration.Compilation { @@ -119,22 +123,33 @@ public static void Add( #pragma warning disable MAAI001 // The loop family is evaluation-only in Microsoft.Agents.AI 1.21.0. /// - /// Wraps one compiled agent in its loop: block: a over the - /// until: evaluators in document order, capped at maxRounds:. No block, no wrap. + /// Wraps one compiled agent in its loop: block: a over the until: + /// evaluators in document order, then the AfterRun hook evaluator, capped at maxRounds: (the framework's + /// default of 10 when the block names none, and 3 with no loop:). No block and no AfterRun hook, no wrap. The + /// loop's own messages never reach the caller. /// /// The compiled agent. /// The agents.defaults section, or . /// The agent being compiled. /// This agent's JSON pointer, for a loop: failure. + /// The compiled hooks; AfterRun is added when one overrides . /// The loop-wrapped agent, or unchanged. - public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, string pointer) + public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, string pointer, HookRuntime hooks) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(hooks); + + bool gated = hooks.Gates.Overrides(GatePoint.AfterRun); if (item.Loop is not { } loop) { - return agent; + return gated + ? new LoopAgent( + agent, + [RunEndEvaluator(hooks)], + new LoopAgentOptions { MaxIterations = RunEndGateChain.DefaultMaxIterations, ExcludeOnBehalfOfMessages = true }) + : agent; } if (loop.Until is not { Count: > 0 }) @@ -185,8 +200,28 @@ public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentCon } } - // Every iteration's text reaches the stream; there are no per-iteration entries (decision 14). - return new LoopAgent(agent, evaluators, new LoopAgentOptions { MaxIterations = loop.MaxRounds }); + if (gated) + { + evaluators.Add(RunEndEvaluator(hooks)); + } + + return new LoopAgent(agent, evaluators, new LoopAgentOptions { MaxIterations = loop.MaxRounds, ExcludeOnBehalfOfMessages = true }); + } + + // Last in the list: MAF acts on the first evaluator that asks to continue, so the document's until: decides + // first. Only ContinueWithMessages sends the hook's message verbatim. + private static DelegateLoopEvaluator RunEndEvaluator(HookRuntime hooks) + { + return new DelegateLoopEvaluator(async (context, cancellationToken) => + await RunEndGateChain.ContinueAsync( + hooks, + TurnInvocation.From(context.RunOptions), + context.Agent.Name, + context.LastResponse.Text, + context.Iteration, + cancellationToken).ConfigureAwait(false) is { } message + ? LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, message)]) + : LoopEvaluation.Stop()); } #pragma warning restore MAAI001 @@ -195,7 +230,7 @@ public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentCon /// /// The state keys of the harness providers among — never the - /// history provider's key, which is the transcript and belongs to store 1. + /// history provider's key, which is the transcript and belongs to the conversation history. /// internal static IReadOnlySet StateKeysOf(IEnumerable providers) { diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs b/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs index c6018469..1c7b4466 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks.Engine; using Microsoft.Agents.AI; namespace AgentCore.Application.Configuration.Compilation @@ -6,13 +7,15 @@ namespace AgentCore.Application.Configuration.Compilation #pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. /// What one walk over agents.items shares between every agent it compiles. /// The agents.defaults section, or . - /// The document's ambiguity wiring (§8), built once rather than re-derived per agent. + /// The document's ambiguity wiring, built once rather than re-derived per agent. /// The providers.knowledge.scope block, or . /// Collects the built background providers, so the conversation can release their sessions when it ends. + /// The hooks of this compile. internal sealed record AgentsWalk( AgentDefaults? Defaults, ResolvedClarification Clarification, KnowledgeScopeConfiguration? Scope, - ICollection Background); + ICollection Background, + HookRuntime Hooks); #pragma warning restore MAAI001 } diff --git a/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs b/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs index 389c2ade..8e74feab 100644 --- a/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs @@ -12,7 +12,7 @@ internal abstract class CompileTableRow /// Gets the this row compiles. internal abstract CompiledAgentShape Shape { get; } - /// Gets whether the row answers its runs out of store 1 on its own session. + /// Gets whether the row answers its runs out of the conversation history on its own session. internal virtual bool SessionCarriesHistory => false; /// Builds the entry agent of one entry, and the stage table when the row has one. @@ -25,7 +25,7 @@ internal abstract class CompileTableRow /// /// 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. + /// 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. diff --git a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs index 1e5dd8b8..dd0e5485 100644 --- a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs +++ b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs @@ -1,5 +1,6 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Policy; using AgentCore.Application.Ports; using AgentCore.Application.Transcript; @@ -46,7 +47,7 @@ internal CompiledAgent(CompiledAgentBuilder builder) } /// - /// Gets whether the row answers its runs out of store 1 on its own session, rather than the + /// Gets whether the row answers its runs out of the conversation history on its own session, rather than the /// request messages. /// internal bool SessionCarriesHistory => _row.SessionCarriesHistory; @@ -83,10 +84,13 @@ internal CompiledAgent(CompiledAgentBuilder builder) internal IReadOnlySet? OutputAgents => _layers.OutputAgents; /// - /// Gets store 1 of every conversation this agent answers. + /// Gets the conversation history of every conversation this agent answers. /// internal AgentCoreChatHistoryProvider History => _document.History; + /// Gets the hooks this entry was compiled with. + internal HookRuntime Hooks => _document.Hooks; + /// Gets the store this agent's conversations and every word of them are kept in. internal IConversationStore ConversationStore => _document.Conversations; diff --git a/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs b/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs index 2f6144e6..168214c2 100644 --- a/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs +++ b/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Ports; using AgentCore.Application.Transcript; @@ -7,9 +8,11 @@ namespace AgentCore.Application.Configuration.Compilation /// What every entry of one document shares once compiled. /// The loaded document. /// The store every conversation and every word of it is kept in. - /// Store 1, over . + /// The conversation history, over . + /// The hooks of this compile, shared by every entry. internal sealed record CompiledDocument( AgentCoreConfiguration Configuration, IConversationStore Conversations, - AgentCoreChatHistoryProvider History); + AgentCoreChatHistoryProvider History, + HookRuntime Hooks); } diff --git a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs index e28bfac0..45f8bb48 100644 --- a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs +++ b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs @@ -1,12 +1,14 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Layers; using AgentCore.Application.Llm; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Configuration.Compilation { /// @@ -76,7 +78,7 @@ private static SingleAgentRow SelectSingleAgentRow(string agentId, string agentP /// Compiles one document into one agent per entry. /// The loaded document. /// The seams the document names. - /// The compiled agents, keyed by entry name. Each is a process singleton: see T44. + /// The compiled agents, keyed by entry name. Each is a process singleton. /// An entry does not compile. public static IReadOnlyDictionary CompileAll( AgentCoreConfiguration configuration, @@ -86,7 +88,8 @@ public static IReadOnlyDictionary CompileAll( ArgumentNullException.ThrowIfNull(context); IConversationStore conversations = context.ConversationStore ?? new InMemoryConversationStore(); - CompiledDocument document = new(configuration, conversations, new AgentCoreChatHistoryProvider(conversations)); + HookRuntime hooks = HookRuntime.Create(context.Hooks ?? [], context.Loggers, context.Clock); + CompiledDocument document = new(configuration, conversations, new AgentCoreChatHistoryProvider(conversations), hooks); Dictionary built = new(StringComparer.Ordinal); Dictionary compiled = new(StringComparer.Ordinal); @@ -100,7 +103,7 @@ public static IReadOnlyDictionary CompileAll( string key = shape is CompiledAgentShape.SingleAgent or CompiledAgentShape.Policy ? "session" : shape.ToString(); if (!built.TryGetValue(key, out CompiledAgentSet? shared)) { - shared = BuildAgents(configuration, context, row.SessionCarriesHistory ? document.History : null); + shared = BuildAgents(configuration, context, row.SessionCarriesHistory ? document.History : null, document.Hooks); built[key] = shared; } @@ -112,7 +115,8 @@ public static IReadOnlyDictionary CompileAll( entry.RefusalReply ?? configuration.RefusalReply, context.Moderation, row.SpokenAuthors(configuration, entry), - document.History); + document.History, + document.Hooks); compiled[entryName] = new CompiledAgentBuilder { @@ -133,12 +137,14 @@ public static IReadOnlyDictionary CompileAll( /// Builds one ChatClientAgent for each agents.items entry. /// The loaded document. /// The seams the document names. - /// Store 1, or to leave the framework default in place. + /// The conversation history, or to leave the framework default in place. + /// The hooks of this compile. /// The agents keyed by id, with what every one of them declared. private static CompiledAgentSet BuildAgents( AgentCoreConfiguration configuration, AgentCompilationContext context, - AgentCoreChatHistoryProvider? history) + AgentCoreChatHistoryProvider? history, + HookRuntime hooks) { Dictionary agents = new(StringComparer.Ordinal); HashSet harnessStateKeys = new(StringComparer.Ordinal); @@ -172,7 +178,8 @@ private static CompiledAgentSet BuildAgents( section.Defaults, ResolvedClarification.From(configuration), configuration.Providers?.Knowledge?.Scope, - backgroundProviders); + backgroundProviders, + hooks); foreach (AgentConfiguration item in section.Items) { @@ -212,10 +219,10 @@ private static CompiledAgentSet BuildAgents( List? compiledTools = AgentToolCompiler.Build( item, item.Model ?? section.Defaults?.Model, tools, context, pointer, Resolve, backgroundProviders); - harnessStateKeys.UnionWith(AgentApproval.StateKeysFor(section.Defaults, item, compiledTools)); + harnessStateKeys.UnionWith(AgentApproval.StateKeysFor(section.Defaults, item, compiledTools, hooks)); ChatClientAgent built = new( - WithToolFailureAuditing(context.ChatClients.GetChatClient(item.Model ?? section.Defaults?.Model)), + WithToolFailureAuditing(context.ChatClients.GetChatClient(item.Model ?? section.Defaults?.Model), hooks), new ChatClientAgentOptions { Id = GraphExecutorId(item.Id), @@ -232,12 +239,12 @@ private static CompiledAgentSet BuildAgents( path.RemoveAt(path.Count - 1); - AIAgent instrumented = new AIAgentBuilder(built) + AIAgent instrumented = new AIAgentBuilder(ToolHookMiddleware.Apply(built, hooks)) .UseOpenTelemetry(configure: static agent => agent.EnableSensitiveData = false) .Build(); - AIAgent approved = AgentApproval.Apply(instrumented, section.Defaults, item); - AIAgent looped = AgentHarnessProviders.ApplyLoop(approved, section.Defaults, item, pointer); + AIAgent approved = AgentApproval.Apply(instrumented, section.Defaults, item, compiledTools, hooks); + AIAgent looped = AgentHarnessProviders.ApplyLoop(approved, section.Defaults, item, pointer, hooks); agents[id] = looped; return looped; @@ -253,13 +260,14 @@ 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) + /// Puts the auditing function-invocation loop and the model gates into the pipeline of one agent. + private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatClient model, HookRuntime hooks) { return new(model.AsBuilder() .UseOpenTelemetry(configure: static client => client.EnableSensitiveData = false) .ConfigureOptions(ConversationRequestStamp.Apply) .Use(static innerClient => new ModelFacingChatClient(innerClient)) + .Use(innerClient => new ModelHookChatClient(innerClient, hooks)) .Build()); } @@ -292,7 +300,6 @@ internal static ConfigurationLoadException Fail(string pointer, string message) Pointer = pointer, Message = message, - // The compile table is a shape rule over the whole document, like check 1. Check = ConfigurationCheck.DocumentSchema, }); } diff --git a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs index 3af4aad6..dc67ce10 100644 --- a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs @@ -1,9 +1,10 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Agents.Graph; namespace AgentCore.Application.Configuration.Compilation { @@ -119,7 +120,7 @@ private static AIAgentBinding BindNode( throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(pointer, "agent"), agentId, entryName); } - return new AIAgentBinding(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = true }); + return new AIAgentBinding(new GraphParticipantAgent(agent), new AIAgentHostOptions { EmitAgentUpdateEvents = true }); } /// Adds every edge, with a gate in front of each guarded one. @@ -193,7 +194,6 @@ private static FunctionExecutor Gate(int index, GuardReference guard, IG true); } - /// An explicit graph answers from its output: true nodes. internal override HashSet? SpokenAuthors(AgentCoreConfiguration configuration, EntryConfiguration entry) { HashSet outputs = new(StringComparer.Ordinal); diff --git a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs index b4d645c3..aa01e22a 100644 --- a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs @@ -1,8 +1,8 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; +using AgentCore.Application.Runtime.Agents.Graph; namespace AgentCore.Application.Configuration.Compilation { @@ -46,7 +46,7 @@ internal override EntryBuild BuildEntry( throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(agentsPointer, index), id, entryName); } - participants.Add(agent); + participants.Add(new GraphParticipantAgent(agent)); } if (participants.Count == 0) @@ -66,11 +66,6 @@ internal override EntryBuild BuildEntry( return new EntryBuild(new GraphFaultAgent(workflow.AsAIAgent(name: entryName), drain: reusesGraphSession), NoStages()); } - /// - /// A sequential graph answers from its last participant. Concurrent aggregates every - /// participant, and handoff and group chat both end wherever the conversation took them, so on - /// those patterns any participant may legitimately speak last and no filter applies. - /// internal override HashSet? SpokenAuthors(AgentCoreConfiguration configuration, EntryConfiguration entry) { return entry.Graph is { Pattern: GraphPattern.Sequential, Agents.Count: > 0 } graph diff --git a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs index 050ed800..e336bf93 100644 --- a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs +++ b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs @@ -1,24 +1,30 @@ using AgentCore.Application.Evaluation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Layers; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Configuration.Compilation { - /// The turn layers one entry puts on every agent a turn runs: the two disposition layers, and the seal outside them. + /// The turn layers one entry puts on every agent a turn runs: the turn gate, moderation and the fallback, 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. - /// Store 1, which the seal commits every turn to. + /// The conversation history, which the seal commits every turn to. + /// The hooks of this compile; the turn gate sits between the seal and moderation when a hook overrides it. internal sealed record TurnLayers( string FallbackReply, string RefusalReply, PromptModerator? Moderation, IReadOnlySet? OutputAgents, - AgentCoreChatHistoryProvider History) + AgentCoreChatHistoryProvider History, + HookRuntime Hooks) { - /// Puts the layers on one agent, outermost first: the seal, then moderation, then the fallback. + /// Puts the layers on one agent, outermost first: the seal, the turn gate, 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) @@ -26,6 +32,11 @@ public AIAgent Apply(AIAgent agent) AIAgentBuilder builder = new AIAgentBuilder(agent) .Use(inner => new ConversationTurnAgent(inner, History)); + if (Hooks.Gates.Overrides(GatePoint.BeforeTurn)) + { + builder = builder.Use(inner => new TurnGateAgent(inner, Hooks, RefusalReply)); + } + if (Moderation is not null) { builder = builder.Use(inner => new ModerationAgent(inner, Moderation, RefusalReply, ModerationAgent.DefaultTimeout)); diff --git a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs index 93b6e8cf..6328d0b6 100644 --- a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs +++ b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs @@ -21,6 +21,19 @@ 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 how long the call gate (BeforeCall) gives each hook to accept or reject an offered call, in seconds, or null + /// for 5. + /// + public int? AnswerSeconds { get; init; } + + /// + /// Gets the live: block: settings that only the vendor names reads and checks. Undefined + /// when the document writes none. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement Live { get; init; } + /// /// Gets the caller-away prompt a voice adapter opens: { timeoutSeconds, say }. Voice conversations only. /// diff --git a/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs index 07d318d8..77dd78f1 100644 --- a/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs +++ b/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs @@ -6,12 +6,6 @@ namespace AgentCore.Application.Configuration.Schema /// /// How a card's payload is named in the collection this deployment reads. /// - /// - /// AgentCore ships no field names. Every role starts unmapped, and stays unmapped until this - /// document names the payload path that fills it. A collection built by any ingester is therefore - /// expressible here, and no ingester's naming is privileged. An unmapped role is simply absent from - /// the card: it is never guessed, and never silently read off a name AgentCore chose. - /// public sealed record KnowledgeFieldsConfiguration { /// @@ -22,11 +16,9 @@ public sealed record KnowledgeFieldsConfiguration public string? Body { get; init; } /// Gets the field holding the card id, or null when the collection carries none. - /// The point's own key stands in for an unmapped id. public string? Id { get; init; } /// Gets the full-text-indexed field the required-term leg matches on. - /// Unmapped, the search ranks by vector alone and the analyzer is never consulted. public string? Lexical { get; init; } /// Gets the field holding the citation's source label. @@ -40,11 +32,6 @@ public sealed record KnowledgeFieldsConfiguration } /// How an open KnowledgeScope's facet keys become payload paths. - /// - /// There is no default template, because a template is a claim about where one particular - /// collection keeps its facets. A deployment whose agents never scope needs none; one whose agents - /// do must say where to look. - /// public sealed record KnowledgeScopeConfiguration { /// @@ -83,11 +70,6 @@ public sealed record KnowledgeFilterableFacetConfiguration /// /// The lookup that turns what the person said into the one value a facet is stored under. /// - /// - /// A vocabulary of thousands cannot sit in a prompt. The model instead searches a small index — the - /// cards under one other facet — and copies the value off the card it finds. This block is printed - /// on the search tool, not run by the host: the model does the lookup itself. - /// public sealed record KnowledgeFacetResolveConfiguration { /// Gets the facet and value that narrow the lookup search to the index cards. @@ -111,10 +93,6 @@ public sealed record KnowledgeFacetValueConfiguration } /// Which facets a card may opt out of, and the value it opts out with. - /// - /// Named per facet, never global. A wildcard reaching an isolation facet such as a customer id - /// would serve one mis-tagged card to every caller, which is the failure the scope exists to stop. - /// public sealed record KnowledgeWildcardConfiguration { /// Gets the payload value that satisfies any scope on a named facet. @@ -138,17 +116,9 @@ public enum KnowledgeLinkLookup } /// How a card's links to other cards are read and followed. - /// - /// The whole block is opt-in: a document without links: never expands. On a configured - /// block, a collection carrying nothing at is still inert. - /// public sealed record KnowledgeLinksConfiguration { /// The uuid5 namespace used when the document names none. - /// - /// This is RFC 4122's own URL namespace, not any ingester's choice. uuid5 is undefined - /// without a namespace, so unlike a field name it cannot be left unset. - /// public const string DefaultNamespace = "url"; /// @@ -226,7 +196,6 @@ public sealed record KnowledgeProviderConfiguration /// /// Gets the IKnowledgeCitationFormatter name that writes each card's source label. /// - /// Read only by agents that declare knowledge: { citations: true }. public string Citation { get; init; } = DefaultCitation; /// @@ -246,7 +215,7 @@ public sealed record KnowledgeProviderConfiguration /// /// How the knowledge search asks the caller which machine they meant, when the scope narrows to more - /// than one and the search itself came back empty. See section 4 of the ambiguity design. + /// than one and the search itself came back empty. /// public sealed record KnowledgeAmbiguityConfiguration { @@ -293,7 +262,7 @@ public sealed record TelemetryProviderConfiguration /// public const int DefaultExportIntervalMilliseconds = 60_000; - /// The lowest interval T61 allows, in milliseconds. + /// The lowest export interval allowed, in milliseconds. public const int MinimumExportIntervalMilliseconds = 60_000; /// The extra ActivitySource names listened to when the document lists none. @@ -319,7 +288,7 @@ public sealed record TelemetryProviderConfiguration /// Gets the service.name every exported signal carries. public string ServiceName { get; init; } = DefaultServiceName; - /// Gets how often metrics are sent, in milliseconds, never below the T61 floor. + /// Gets how often metrics are sent, in milliseconds, never below the minimum. [JsonPropertyName("exportIntervalMs")] public int ExportIntervalMilliseconds { @@ -348,9 +317,6 @@ public sealed record ProvidersConfiguration /// Gets the two speech roles, or . public SpeechProviderConfiguration? Speech { get; init; } - /// Gets the telephony provider, or . - public VendorProviderConfiguration? Telephony { get; init; } - /// Gets the moderation provider, or . public VendorProviderConfiguration? Moderation { get; init; } diff --git a/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs index aed0475b..cd3e7480 100644 --- a/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs +++ b/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs @@ -1,7 +1,7 @@ namespace AgentCore.Application.Configuration.Schema { /// - /// A provider that names one vendor and nothing else, such as speech or telephony. + /// A provider that names one vendor and nothing else, such as audit or conversations. /// public sealed record VendorProviderConfiguration { diff --git a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json index 7184c264..a9ef166b 100644 --- a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json +++ b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json @@ -1190,8 +1190,7 @@ "providers": { "type": "object", "required": [ - "conversation", - "speech" + "conversation" ], "additionalProperties": false, "properties": { @@ -1208,9 +1207,6 @@ "speech": { "$ref": "#/$defs/speechProvider" }, - "telephony": { - "$ref": "#/$defs/vendorProvider" - }, "moderation": { "$ref": "#/$defs/vendorProvider", "description": "The adapter that reads what the caller said through a moderation endpoint, such as openai. A document that names none moderates nothing." @@ -1338,7 +1334,7 @@ }, "conversationProvider": { "type": "object", - "description": "The media plane: who carries the conversation and owns its inbound route. Not providers.speech, which is recognition and synthesis, and not providers.telephony, which is conversation control.", + "description": "The media plane: who carries the conversation and owns its inbound route. Not providers.speech, which is recognition and synthesis.", "required": [ "kind" ], @@ -1366,6 +1362,8 @@ "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." }, + "answerSeconds": { "type": "integer", "minimum": 1, "maximum": 60, "description": "How long the call gate gives each hook to accept or reject an offered call, in seconds. Omit it for 5." }, + "live": { "type": "object", "description": "The openai-live vendor's own settings: instructions, voice, model. Only that adapter reads and checks them." }, "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.", diff --git a/src/AgentCore.Application/Conversation/ConversationRecord.cs b/src/AgentCore.Application/Conversation/ConversationRecord.cs index 6893f146..3edcea38 100644 --- a/src/AgentCore.Application/Conversation/ConversationRecord.cs +++ b/src/AgentCore.Application/Conversation/ConversationRecord.cs @@ -2,7 +2,7 @@ namespace AgentCore.Application.Conversation { - /// One conversation, apart from its words. It is one row of store 0. + /// One conversation, apart from its words. It is one row of the conversation store. /// The conversation this describes. It is the join to stores 1 and 3. /// What to show in a list, or until one is made. /// Whether the conversation is still listed as usual. @@ -10,8 +10,8 @@ namespace AgentCore.Application.Conversation /// A consumer's own fields, or . /// When the conversation row was made. UTC. /// - /// When store 1 last wrote a word of this conversation, or when it holds none. UTC. - /// Derived at read time; store 0 keeps no such column. + /// When the message store last wrote a word of this conversation, or when it holds none. UTC. + /// Derived at read time; the conversation store keeps no such column. /// /// /// What the session of this conversation held, or for a conversation that has run no turn diff --git a/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs b/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs index 54fa0385..9a5de7e4 100644 --- a/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs +++ b/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs @@ -7,7 +7,7 @@ namespace AgentCore.Application.Conversation { /// - /// Opens the store 0 backing the document names, from the adapters the host registered. + /// Opens the conversation store the document names, from the adapters the host registered. /// public static class ConversationStoreFactory { diff --git a/src/AgentCore.Application/Conversation/Conversations.cs b/src/AgentCore.Application/Conversation/Conversations.cs index 5d999f22..a04ec4ae 100644 --- a/src/AgentCore.Application/Conversation/Conversations.cs +++ b/src/AgentCore.Application/Conversation/Conversations.cs @@ -174,6 +174,12 @@ public ValueTask> AppendAsync( return Store.AppendAsync(conversationId, messages, state, cancellationToken); } + /// + public ValueTask SaveStateAsync(string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default) + { + return Store.SaveStateAsync(conversationId, state, cancellationToken); + } + /// public ValueTask AppendMessageAsync(string conversationId, ChatMessage message, CancellationToken cancellationToken = default) { diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs new file mode 100644 index 00000000..2e7cd43f --- /dev/null +++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs @@ -0,0 +1,38 @@ +namespace AgentCore.Application.Conversation.Memory +{ + /// + /// The resume state of each conversation, kept beside its row rather than on it. A state never replaces a newer + /// one. The store's lock guards every call. + /// + internal sealed class InMemoryConversationStates + { + private readonly Dictionary _states = new(StringComparer.Ordinal); + + /// Reads a conversation's state, or when none is stored. + internal ConversationSessionState? Of(string conversationId) + { + return _states.GetValueOrDefault(conversationId); + } + + /// Reads the turn a conversation takes next, by its stored state. + internal int NextTurnIndex(string conversationId) + { + return Of(conversationId)?.NextTurnIndex ?? 0; + } + + /// Keeps a state, unless it is or the stored one names a later next turn. + internal void Save(string conversationId, ConversationSessionState? state) + { + if (state is not null && state.NextTurnIndex >= NextTurnIndex(conversationId)) + { + _states[conversationId] = state; + } + } + + /// Drops a conversation's state. + internal void Forget(string conversationId) + { + _ = _states.Remove(conversationId); + } + } +} diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs index 47e40d91..186ed584 100644 --- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs +++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs @@ -6,7 +6,6 @@ namespace AgentCore.Application.Conversation.Memory { /// The store backing that keeps every row in this process: a conversation, and its words. - /// Creates the store. /// Where created_at comes from, or for the system clock. public sealed class InMemoryConversationStore(TimeProvider? timeProvider = null) : IConversationStore { @@ -16,8 +15,7 @@ public sealed class InMemoryConversationStore(TimeProvider? timeProvider = null) private readonly InMemoryConversationWords _words = new(); - /// The resume blob of each conversation, beside the row rather than on it. - private readonly Dictionary _state = []; + private readonly InMemoryConversationStates _states = new(); private readonly InMemoryConversationClaims _claims = new(); @@ -43,7 +41,7 @@ public ValueTask CreateAsync(string conversationId, Cancella return ValueTask.FromResult(existing with { - State = _state.GetValueOrDefault(conversationId), + State = _states.Of(conversationId), NextOrdinal = _words.NextOrdinal(conversationId), }); } @@ -62,7 +60,7 @@ public ValueTask CreateAsync(string conversationId, Cancella ? null : conversation with { - State = _state.GetValueOrDefault(conversationId), + State = _states.Of(conversationId), NextOrdinal = _words.NextOrdinal(conversationId), }); } @@ -130,12 +128,7 @@ public ValueTask DeleteAsync(string conversationId, CancellationToken cancellati { ArgumentNullException.ThrowIfNull(conversationId); - lock (_lock) - { - Forget(conversationId); - } - - return default; + return UnderLock(() => Forget(conversationId)); } /// @@ -175,12 +168,7 @@ public ValueTask AttachPrincipalAsync( ArgumentNullException.ThrowIfNull(principalKey); ArgumentNullException.ThrowIfNull(role); - lock (_lock) - { - _claims.Attach(conversationId, principalKey); - } - - return default; + return UnderLock(() => _claims.Attach(conversationId, principalKey)); } /// @@ -190,12 +178,7 @@ public ValueTask DetachPrincipalAsync( ArgumentNullException.ThrowIfNull(conversationId); ArgumentNullException.ThrowIfNull(principalKey); - lock (_lock) - { - _claims.Detach(conversationId, principalKey); - } - - return default; + return UnderLock(() => _claims.Detach(conversationId, principalKey)); } /// @@ -217,10 +200,10 @@ public ValueTask> AppendAsync( { if (!_conversations.TryGetValue(conversationId, out ConversationRecord? conversation)) { - throw new InvalidOperationException($"Store 0 holds no conversation '{conversationId}' to append words to."); + throw new InvalidOperationException($"The conversation store holds no conversation '{conversationId}' to append words to."); } - int fallbackTurnIndex = _state.GetValueOrDefault(conversationId)?.NextTurnIndex ?? 0; + int fallbackTurnIndex = _states.NextTurnIndex(conversationId); if (messages.Min(message => message.TurnIndex) is { } named && named < fallbackTurnIndex) { throw new ConversationTurnConflictException( @@ -232,15 +215,26 @@ public ValueTask> AppendAsync( DateTimeOffset now = _time.GetUtcNow(); _conversations[conversationId] = conversation with { LastMessageAt = now }; - if (state is not null && state.NextTurnIndex >= fallbackTurnIndex) - { - _state[conversationId] = state; - } + _states.Save(conversationId, state); return ValueTask.FromResult(rows); } } + /// + public ValueTask SaveStateAsync( + string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(conversationId); + ArgumentNullException.ThrowIfNull(state); + + return Amend(conversationId, conversation => + { + _states.Save(conversationId, state); + return conversation; + }); + } + /// public ValueTask RewriteAsync( string conversationId, string messageId, ChatMessage content, CancellationToken cancellationToken = default) @@ -249,12 +243,7 @@ public ValueTask RewriteAsync( ArgumentException.ThrowIfNullOrEmpty(messageId); ArgumentNullException.ThrowIfNull(content); - lock (_lock) - { - _words.Rewrite(conversationId, messageId, content); - } - - return default; + return UnderLock(() => _words.Rewrite(conversationId, messageId, content)); } /// @@ -263,12 +252,7 @@ public ValueTask DeleteMessageAsync(string conversationId, string messageId, Can ArgumentNullException.ThrowIfNull(conversationId); ArgumentException.ThrowIfNullOrEmpty(messageId); - lock (_lock) - { - _words.Delete(conversationId, messageId); - } - - return default; + return UnderLock(() => _words.Delete(conversationId, messageId)); } /// @@ -277,10 +261,7 @@ public ValueTask> ReadForSessionAsync( { ArgumentNullException.ThrowIfNull(conversationId); - lock (_lock) - { - return ValueTask.FromResult(_words.ReadForSession(conversationId)); - } + return UnderLock(() => _words.ReadForSession(conversationId)); } /// @@ -289,10 +270,7 @@ public ValueTask> ReadWindowAsync( { ArgumentNullException.ThrowIfNull(conversationId); - lock (_lock) - { - return ValueTask.FromResult(_words.Read(conversationId, window)); - } + return UnderLock(() => _words.Read(conversationId, window)); } /// @@ -302,10 +280,7 @@ public ValueTask> ReadWindowAsync( ArgumentNullException.ThrowIfNull(conversationId); ArgumentException.ThrowIfNullOrEmpty(messageId); - lock (_lock) - { - return ValueTask.FromResult(_words.OrdinalOf(conversationId, messageId)); - } + return UnderLock(() => _words.OrdinalOf(conversationId, messageId)); } /// @@ -314,10 +289,7 @@ public ValueTask TruncateAsync( { ArgumentNullException.ThrowIfNull(conversationId); - lock (_lock) - { - return ValueTask.FromResult(_words.Truncate(conversationId, fromOrdinal)); - } + return UnderLock(() => _words.Truncate(conversationId, fromOrdinal)); } /// @@ -325,10 +297,7 @@ public ValueTask EraseAsync(string conversationId, CancellationToken cancel { ArgumentNullException.ThrowIfNull(conversationId); - lock (_lock) - { - return ValueTask.FromResult(_words.Erase(conversationId)); - } + return UnderLock(() => _words.Erase(conversationId)); } /// @@ -337,12 +306,7 @@ public ValueTask SaveContinuationAsync(string responseId, string conversationId, ArgumentNullException.ThrowIfNull(responseId); ArgumentNullException.ThrowIfNull(conversationId); - lock (_lock) - { - _continuations.Save(responseId, conversationId, _time.GetUtcNow()); - } - - return default; + return UnderLock(() => _continuations.Save(responseId, conversationId, _time.GetUtcNow())); } /// @@ -350,10 +314,7 @@ public ValueTask SaveContinuationAsync(string responseId, string conversationId, { ArgumentNullException.ThrowIfNull(responseId); - lock (_lock) - { - return ValueTask.FromResult(_continuations.Find(responseId)); - } + return UnderLock(() => _continuations.Find(responseId)); } /// @@ -361,12 +322,7 @@ public ValueTask DeleteContinuationAsync(string responseId, CancellationToken ca { ArgumentNullException.ThrowIfNull(responseId); - lock (_lock) - { - _continuations.Forget(responseId); - } - - return default; + return UnderLock(() => _continuations.Forget(responseId)); } /// Drops a conversation, its claims, its state, its continuation and its words. Runs under the lock. @@ -374,11 +330,29 @@ private void Forget(string conversationId) { _ = _conversations.Remove(conversationId); _claims.Forget(conversationId); - _ = _state.Remove(conversationId); + _states.Forget(conversationId); _continuations.ForgetConversation(conversationId); _words.Forget(conversationId); } + private ValueTask UnderLock(Action work) + { + lock (_lock) + { + work(); + } + + return default; + } + + private ValueTask UnderLock(Func read) + { + lock (_lock) + { + return ValueTask.FromResult(read()); + } + } + private ValueTask Amend(string conversationId, Func amend) { lock (_lock) diff --git a/src/AgentCore.Application/Diagnostics/Log.cs b/src/AgentCore.Application/Diagnostics/Log.cs index 840cd3ba..4726278d 100644 --- a/src/AgentCore.Application/Diagnostics/Log.cs +++ b/src/AgentCore.Application/Diagnostics/Log.cs @@ -5,13 +5,12 @@ namespace AgentCore.Application.Diagnostics { /// - /// 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, + /// Every line the turn loop writes: extraction, moderation, the transcript, and knowledge retrieval. The session owner's own lines — its workspace folder, /// and its close and idle-expiry routine — are . /// internal static partial class Log { - /// Section 8.7, row two. The extractor returned an invalid object. + /// The extractor returned an invalid object. /// The logger of the session. /// The id of the conversation. /// The zero-based index of the turn that just ran. @@ -23,7 +22,7 @@ internal static partial class Log + "The slots stay unchanged and the conversation continues.")] public static partial void ExtractionFailed(ILogger logger, string conversationId, int turnIndex, string reason); - /// Section 8.7, row six. A tool failed four times in a row and the run threw. + /// A tool failed four times in a row and the run threw. /// The logger of the session. /// The id of the conversation. /// The zero-based index of the turn that just ran. @@ -36,7 +35,7 @@ internal static partial class Log 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. + /// A fault above the fallback layer, so no tool ever ran. /// /// The logger of the session. /// The id of the conversation. @@ -49,7 +48,7 @@ internal static partial class Log + "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 run returned quietly with no text. /// The logger of the session. /// The id of the conversation. /// The zero-based index of the turn that just ran. @@ -60,7 +59,7 @@ internal static partial class Log + "The run reached 40 tool rounds, or the model answered nothing.")] public static partial void EmptyReply(ILogger logger, string conversationId, int turnIndex); - /// Section 8.7, row five. A guard threw at run time, or its rule did not parse. + /// A guard threw at run time, or its rule did not parse. /// The logger of the composition root. /// The guard name, or the rule text of an inline guard. /// The cause. @@ -70,7 +69,7 @@ internal static partial class Log Message = "The guard {Guard} failed. It is treated as false and the conversation continues.")] public static partial void GuardFailed(ILogger logger, string guard, Exception exception); - /// An observer of the conversation refused an event, or faulted behind its own enqueue. + /// The audit store refused a row, or the queue behind it faulted. /// The logger of the session. /// The id of the conversation. /// The wire token of the event kind. @@ -106,18 +105,21 @@ internal static partial class Log + "The turn ran unchecked, because moderation fails open.")] public static partial void ModerationUnavailable(ILogger logger, string conversationId, int turnIndex, string reason); - /// The audit queue had no room, so the event was dropped. + /// + /// The audit queue did not take an event: the queue was closed, or the caller cancelled the append. A full queue + /// makes the caller wait instead. + /// /// The logger of the queue. /// The id of the conversation the dropped event belongs to. /// The identity of the dropped event. [LoggerMessage( EventId = 8, Level = LogLevel.Error, - Message = "The audit queue was full, so event {EventId} of conversation {ConversationId} was dropped. " - + "The conversation continues and the chain has a gap.")] + Message = "The audit queue did not take event {EventId} of conversation {ConversationId}: the queue was closed, " + + "or the append was cancelled. The conversation continues and the chain has a gap.")] public static partial void AuditQueueFull(ILogger logger, string conversationId, Guid eventId); - /// A store 1 write was refused, so the turn has no durable record. + /// A conversation history write was refused, so the turn has no durable record. /// The logger of the session. /// The id of the conversation. /// The zero-based index of the turn that was being written. @@ -141,7 +143,7 @@ internal static partial class Log public static partial void KnowledgeRetrieved( ILogger logger, string agent, int cardCount, KnowledgeAuditRecord.LogView record); - /// A19. One knowledge retrieval threw, and this is the only place the cause survives. + /// One knowledge retrieval threw, and this is the only place the cause survives. /// The logger of the knowledge provider. /// The id of the agent that asked. /// The loggable part of the retrieval, as a structured field. @@ -196,7 +198,7 @@ public static partial void KnowledgeScopeComposed( int turnIndex, IReadOnlyDictionary origins); - /// §8's probe answered: it dropped one facet, re-searched, and found this many candidates. + /// The probe answered: it dropped one facet, re-searched, and found this many candidates. /// The logger of the knowledge provider. /// The id of the agent that asked. /// The facet the probe dropped. @@ -208,7 +210,7 @@ public static partial void KnowledgeScopeComposed( public static partial void KnowledgeProbeRan(ILogger logger, string agent, string facet, int candidateCount); /// - /// §8 step 4's probe search threw or timed out. The turn was told the knowledge base holds nothing + /// The probe search threw or timed out. The turn was told the knowledge base holds nothing /// rather than that it is unreachable, because the main search that already ran is what answers for /// reachability; the probe is an extra question on top of it. /// @@ -242,7 +244,7 @@ public static partial void KnowledgeScopeComposed( + "the tool from this agent.")] public static partial void HostedToolDropped(ILogger logger, string agentId, string toolId, string marker, string model); - /// Store 1 could not be read as a turn opened, so the turn ran on the words already held. + /// The conversation history could not be read as a turn opened, so the turn ran on the words already held. /// The logger of the session. /// The id of the conversation. /// The zero-based index of the turn that was opening. diff --git a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs b/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs deleted file mode 100644 index 82b1be94..00000000 --- a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs +++ /dev/null @@ -1,111 +0,0 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace AgentCore.Application.Diagnostics -{ - /// - /// Writes the "log once" rows of section 8.7, one line for each fact that earns one. - /// - /// - /// Where the lines go, or for . The - /// library never throws for want of one. - /// - internal sealed class LoggingConversationObserver(ILogger? logger = null) : IConversationObserver - { - /// The turn a line names when the fact carried no index. No turn has it. - private const int NoTurn = -1; - - private readonly ILogger _logger = logger ?? NullLogger.Instance; - - /// - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(conversationEvent); - - switch (conversationEvent.Kind) - { - case ConversationEventKind.EmptyReply: - Log.EmptyReply(_logger, conversationEvent.ConversationId, TurnOf(conversationEvent)); - break; - - case ConversationEventKind.ExtractionFailed: - Log.ExtractionFailed( - _logger, - conversationEvent.ConversationId, - TurnOf(conversationEvent), - Detail(conversationEvent, ConversationEventPayloadKeys.Reason)); - break; - - case ConversationEventKind.PromptFlagged: - Log.PromptRefused( - _logger, - conversationEvent.ConversationId, - TurnOf(conversationEvent), - Detail(conversationEvent, AuditPayloadKeys.ModerationCategories)); - break; - - case ConversationEventKind.ModerationUnavailable: - Log.ModerationUnavailable( - _logger, - conversationEvent.ConversationId, - TurnOf(conversationEvent), - Detail(conversationEvent, ConversationEventPayloadKeys.Reason)); - break; - - case ConversationEventKind.StateRestorePartial: - 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.ConversationEnded: - break; - - case ConversationEventKind.TranscriptWriteFailed: - case ConversationEventKind.TranscriptResyncFailed: - case ConversationEventKind.ToolFailed: - case ConversationEventKind.RunFaulted: - break; - - default: - break; - } - - return ValueTask.CompletedTask; - } - - /// Reads the turn a line names. - /// The fact being reported. - /// Its turn index, or when it carried none. - private static int TurnOf(ConversationEvent conversationEvent) - { - return conversationEvent.TurnIndex ?? NoTurn; - } - - /// Reads one detail a line reports. - /// The fact being reported. - /// The payload key the detail sits under. - /// The detail, or an empty string when the fact carried none. - 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/TelemetryConversationObserver.cs b/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs deleted file mode 100644 index 984e22e0..00000000 --- a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs +++ /dev/null @@ -1,76 +0,0 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; - -namespace AgentCore.Application.Diagnostics -{ - /// - /// Counts the facts of a conversation on the three instruments of section 8.6. - /// - internal sealed class TelemetryConversationObserver : IConversationObserver - { - /// - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(conversationEvent); - - switch (conversationEvent.Kind) - { - case ConversationEventKind.PromptFlagged: - AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationFlagged); - break; - - case ConversationEventKind.ModerationUnavailable: - AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationUnavailable); - break; - - case ConversationEventKind.ModerationClean: - AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationClean); - break; - - case ConversationEventKind.ToolFailed: - 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; - - case ConversationEventKind.ConversationStarted: - case ConversationEventKind.TurnCompleted: - case ConversationEventKind.ReplyInterrupted: - case ConversationEventKind.TurnSuperseded: - case ConversationEventKind.TurnRefused: - case ConversationEventKind.ConversationEnded: - break; - - case ConversationEventKind.TranscriptWriteFailed: - case ConversationEventKind.StateRestorePartial: - case ConversationEventKind.TranscriptResyncFailed: - break; - - default: - break; - } - - if (ConversationEventKinds.TryGetAuditKind(conversationEvent.Kind, out AuditEventKind auditKind)) - { - AgentCoreTelemetry.RecordAuditEvent(AuditEventKinds.ToToken(auditKind)); - } - - return ValueTask.CompletedTask; - } - } -} diff --git a/src/AgentCore.Application/Evaluation/ModerationVerdict.cs b/src/AgentCore.Application/Evaluation/ModerationVerdict.cs index 723f090c..96c3cef5 100644 --- a/src/AgentCore.Application/Evaluation/ModerationVerdict.cs +++ b/src/AgentCore.Application/Evaluation/ModerationVerdict.cs @@ -5,33 +5,6 @@ namespace AgentCore.Application.Evaluation /// /// What the moderation endpoint answered about one piece of text. /// - /// - /// - /// Decision D13 makes the moderation port and refuses a second one, so the - /// verdict has to travel on the result the evaluator already returns. This type is that carrier. The - /// moderation adapter attaches it to its metric, and the caller reads it back through - /// . Nothing between the two parses a metric name, a reason, or a metadata - /// string. - /// - /// - /// It lives in Application/Evaluation/ and not beside the adapter, for two reasons. D3 forbids - /// AgentCore.Application from referencing AgentCore.Infrastructure, and the code that - /// records a flagged turn sits in , which is in Application. And - /// D13 promises that replacing OpenAI with a self-hosted classifier behind the same - /// is a one-file change, which holds only while this type names no vendor. - /// - /// - /// The categories keep the order the endpoint returned them in, because - /// promises that order to a reader - /// of the audit chain. Nothing here sorts them, renames them, or checks them against a list: the - /// taxonomy belongs to the endpoint and it is open. A category this library never named still - /// reaches the chain. - /// - /// - /// is the shape this follows: an that - /// carries a typed payload beside a metric. - /// - /// public sealed class ModerationVerdict : EvaluationContext { /// The name this verdict carries on the metric. @@ -47,11 +20,6 @@ public sealed class ModerationVerdict : EvaluationContext /// endpoint flagged nothing. /// /// The categories are . - /// - /// The endpoint owns , and this type never recomputes it from - /// . A model that flags text without naming a category still reads - /// as flagged, and a threshold the vendor moves does not drift the answer. - /// public ModerationVerdict(bool flagged, IEnumerable categories) : this(flagged, Materialize(categories)) { @@ -75,18 +43,6 @@ private ModerationVerdict(bool flagged, string[] categories) /// The verdict, when one metric carries it. /// when the endpoint answered. /// The result is . - /// - /// - /// A verdict is present only when the endpoint answered. An evaluation that failed, timed - /// out, or read a body it could not parse carries an inconclusive metric and no verdict, so this - /// answers . The caller then knows the text is unchecked, which is a - /// different fact from "the endpoint checked it and flagged nothing". - /// - /// - /// It searches every metric and names none, so the caller compiles against this assembly alone - /// and never against the adapter that produced the result. - /// - /// public static bool TryRead(EvaluationResult result, out ModerationVerdict? verdict) { ArgumentNullException.ThrowIfNull(result); diff --git a/src/AgentCore.Application/Evaluation/PromptModerator.cs b/src/AgentCore.Application/Evaluation/PromptModerator.cs index 2bac8dea..0a4b369d 100644 --- a/src/AgentCore.Application/Evaluation/PromptModerator.cs +++ b/src/AgentCore.Application/Evaluation/PromptModerator.cs @@ -6,32 +6,6 @@ namespace AgentCore.Application.Evaluation /// /// Reads which moderation categories flagged what the caller said. /// - /// - /// - /// The agent moderates the caller's words BEFORE the model runs, and refuses the turn when the - /// endpoint flags them. That is a decision of the owner, taken on 2026-08-13, and it departs from - /// section 11 item 11, which asked for the agent's reply to be moderated and recorded instead. - /// Reply moderation is withdrawn, not deferred: the reply comes from a model that already - /// carries safety training, so a second check of its own output buys little. The caller's words are - /// the text nothing else has filtered. - /// - /// - /// D13 makes the moderation port and refuses a second one, so this type is - /// not a port. It is the one place that turns an into the fact the - /// audit chain records, and it exists so that holds no registry - /// key, no metric name, and no decode. - /// - /// - /// D13 also says moderation checks EVERY turn, because the endpoint is free at any volume and counts - /// against no usage limit: "Sampling buys nothing when the conversation is free." Nothing here reads - /// , and nothing should. - /// - /// - /// Nothing here catches. A timeout, a log line, and the decision to answer anyway all belong - /// to , which owns the turn and the clock. This type calls the - /// evaluator and reads the answer. - /// - /// public sealed class PromptModerator { /// The name the composition root registers the moderation evaluator under. @@ -57,11 +31,6 @@ public PromptModerator(IEvaluator evaluator) /// . /// /// The registry is . - /// - /// A host that registers no moderation evaluator runs every turn and refuses none, exactly as a - /// host that binds no audit sink still answers a conversation. Moderation needs a vendor account, and a - /// library that refused to run without one would be unusable in a test. - /// public static PromptModerator? FromRegistry(EvaluatorRegistry registry) { ArgumentNullException.ThrowIfNull(registry); @@ -79,13 +48,6 @@ public PromptModerator(IEvaluator evaluator) /// flagged nothing. /// /// The text is . - /// - /// An endpoint that did not answer reads as "nothing flagged", and the turn goes on. That - /// is the fail-open rule, and it is deliberate: a vendor outage must not refuse every caller on - /// a support line. answers for a - /// failed evaluation, so the two cases meet here and the caller of this method cannot tell them - /// apart. counts them apart on a metric instead. - /// public async ValueTask> FlaggedCategoriesAsync( string callerText, CancellationToken cancellationToken = default) diff --git a/src/AgentCore.Application/Hooks/AgentHook.cs b/src/AgentCore.Application/Hooks/AgentHook.cs new file mode 100644 index 00000000..d55e754e --- /dev/null +++ b/src/AgentCore.Application/Hooks/AgentHook.cs @@ -0,0 +1,278 @@ +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks +{ + /// + /// The one hook class. Consumers and AgentCore itself derive from it and override only what they need. + /// + public abstract class AgentHook + { + /// A transport offers a call (GPT-Live webhook, Telnyx setup frame). Decide with or . + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) => default; + + /// Every AgentCore route, HTTP or call, needs an entry. Decide with or . + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) => default; + + /// The user's message arrives, before moderation and the model. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) => default; + + /// Once per run, when the context is built. A graph participant or an agent-as-tool child is a run of its own, so it fires again with set. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) => default; + + /// AgentCore is about to compact. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) => default; + + /// Before each model round trip. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => default; + + /// After each round trip, before tools run or text leaves. Overriding this method buffers every streamed model round of every turn before any of it reaches the caller. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => default; + + /// A round trip throws. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) => default; + + /// A tool that needs approval is called. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) => default; + + /// Before each tool call. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + + /// After a tool returns. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => default; + + /// A tool throws. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask AfterToolFailedAsync(ToolFailureGate gate, CancellationToken cancellationToken) => default; + + /// The run has a reply, before the turn is sealed. + /// The facts and the verbs of this gate. + /// Cancelled at the gate's deadline. + /// A task that completes when the hook decided. + public virtual ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) => default; + + /// Boot finished. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => default; + + /// Shutdown begins. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) => default; + + /// The hourly retention sweep ran. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) => default; + + /// A call is accepted and the session is open. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) => default; + + /// A spoken line closes. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) => default; + + /// After the store opens. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => default; + + /// Idle unload or close; the conversation lives on in the store. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => default; + + /// The conversation ends. Once per conversation. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => default; + + /// The turn is admitted and its agent chosen. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => default; + + /// Any refusal. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => default; + + /// Edit-and-resend withdrew turns. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => default; + + /// Moderation decided. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => default; + + /// A knowledge search finished. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) => default; + + /// A skill was pinned or loaded. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) => default; + + /// Compaction finished or failed. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) => default; + + /// Each output update that leaves the turn. Overriding this method makes AgentCore raise it for every update. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) => default; + + /// Each model round trip ended. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) => default; + + /// Each tool call ended. One per call. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => default; + + /// Approval asked or answered. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) => default; + + /// file.publish stored a file. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) => default; + + /// An agent-as-tool run began. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) => default; + + /// An agent-as-tool run ended. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) => default; + + /// The turn is sealed. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => default; + + /// The caller spoke over the reply (cut or recut). + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => default; + + /// The user or agent voice state changed. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) => default; + + /// Voice turn timing is known. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) => default; + + /// The stage machine moved. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) => default; + + /// A non-fatal fault. + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => default; + + /// A started call left. Once per call, also after . + /// The fact. + /// Cancelled when passes. + /// A task that completes when the hook is done with the notice. + public virtual ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) => default; + + /// Gets how a gate of this hook fails. Override per point to fail closed where the gate fails open. + /// The gate point. + /// The point's unless overridden. + public virtual HookFailure FailureFor(GatePoint point) + { + ArgumentNullException.ThrowIfNull(point); + return point.DefaultFailure; + } + + /// + /// Gets how long one notice may take before AgentCore abandons it and goes on, or to + /// wait for every notice, logging every 30 s while it waits. + /// + public virtual TimeSpan? NoticeTimeout => TimeSpan.FromSeconds(30); + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs new file mode 100644 index 00000000..0e9fd22a --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Hooks.BuiltIn +{ + /// + /// Turns the durable facts of a conversation into the append-only audit chain: the 8 audit kinds, with + /// as the row id. It never times out, so a slow store is waited out, + /// and behind QueuedAuditSink a full queue makes it wait rather than drop. + /// + /// Where the rows go. + /// Where a refused row is reported. + internal sealed class AuditHook(IAuditSinkPort sink, ILogger logger) : AgentHook + { + private static readonly IReadOnlyDictionary NoPayload = new Dictionary(StringComparer.Ordinal); + + /// + public override TimeSpan? NoticeTimeout => null; + + /// + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => + AppendAsync(notice, AuditEventKind.ConversationStarted, NoPayload, amends: null, cancellationToken); + + /// + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) + { + Dictionary payload = new(StringComparer.Ordinal) { [AuditPayloadKeys.EndReason] = ConversationEndReasons.ToToken(notice.Reason) }; + if (notice.TerminalStage is { Length: > 0 } stage) + { + payload[AuditPayloadKeys.StageAfter] = stage; + } + + return AppendAsync(notice, AuditEventKind.ConversationEnded, payload, amends: null, cancellationToken); + } + + /// + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => + !AuditRules.IsAudited(notice) + ? ValueTask.CompletedTask + : AppendAsync(notice, AuditEventKind.TurnRefused, + new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.RefusedReason] = TurnRefusalTokens.ToToken(notice.Reason) }, + amends: null, cancellationToken); + + /// + public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => + AppendAsync(notice, AuditEventKind.TurnSuperseded, new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.WithdrewFromTurnIndex] = notice.WithdrewFrom.ToString(CultureInfo.InvariantCulture), + [AuditPayloadKeys.WithdrewThroughTurnIndex] = notice.WithdrewThrough.ToString(CultureInfo.InvariantCulture), + }, amends: null, cancellationToken); + + /// + public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => + !AuditRules.IsAudited(notice) + ? ValueTask.CompletedTask + : AppendAsync(notice, AuditEventKind.PromptFlagged, + new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.ModerationCategories] = string.Join(',', notice.Categories) }, + amends: null, cancellationToken); + + /// + public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => + !AuditRules.IsAudited(notice) + ? ValueTask.CompletedTask + : AppendAsync(notice, AuditEventKind.ToolFailed, new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ToolName] = notice.ToolName, + [AuditPayloadKeys.ToolCallId] = notice.CallId, + [AuditPayloadKeys.ToolFailureKind] = ToolFailureKinds.ToToken(notice.FailureKind ?? ToolFailureKind.Faulted), + [AuditPayloadKeys.ToolError] = notice.Failure ?? string.Empty, + }, amends: null, cancellationToken); + + /// + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => + AppendAsync(notice, AuditEventKind.TurnCompleted, new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(notice.ReplyText).Value, + [AuditPayloadKeys.StageBefore] = notice.StageBefore, + [AuditPayloadKeys.StageAfter] = notice.StageAfter, + }, amends: null, cancellationToken); + + /// + public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) + { + Dictionary payload = new(StringComparer.Ordinal) + { + [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(notice.HeardText).Value, + }; + + if (notice.Played is { } played) + { + payload[AuditPayloadKeys.DurationUntilInterruptMs] = ((long)played.TotalMilliseconds).ToString(CultureInfo.InvariantCulture); + } + + return AppendAsync(notice, AuditEventKind.ReplyInterrupted, payload, notice.AmendsEventId, cancellationToken); + } + + private async ValueTask AppendAsync( + HookNotice notice, AuditEventKind kind, IReadOnlyDictionary payload, Guid? amends, CancellationToken cancellationToken) + { + AuditEvent row = new() + { + ConversationId = notice.Scope.ConversationId!, + EventId = notice.EventId, + Kind = kind, + OccurredAt = notice.Scope.OccurredAt, + TurnIndex = notice.Scope.TurnIndex, + AmendsEventId = amends, + Payload = payload, + }; + + try + { + await sink.AppendAsync(row, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + Log.AuditAppendFailed(logger, row.ConversationId, AuditEventKinds.ToToken(kind), exception); + throw; + } + } + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs b/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs new file mode 100644 index 00000000..35efe788 --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs @@ -0,0 +1,21 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks.BuiltIn +{ + /// + /// Which facts the audit chain writes a row for, where a notice type alone does not say. The audit hook writes + /// by these rules and the telemetry hook counts by them, so the counter never drifts from the rows. + /// + internal static class AuditRules + { + /// A fault the model cannot answer, or a tool the model named that no one declared, is a tool.failed row. + internal static bool IsAudited(ToolCalled notice) => + notice is { Outcome: ToolOutcome.Failed, Fatal: true } or { Outcome: ToolOutcome.Undeclared }; + + /// A refusal after conversation.ended is no row: nothing is appended behind the end. + internal static bool IsAudited(TurnRefused notice) => !notice.AfterEnd; + + /// Only flagged words are a prompt.flagged row. + internal static bool IsAudited(InputModerated notice) => notice.Verdict == InputVerdict.Flagged; + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs new file mode 100644 index 00000000..88d9c116 --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Transcript; + +namespace AgentCore.Application.Hooks.BuiltIn +{ + /// + /// Adds an accepted call's brief to every run of the call's conversation, as instructions. + /// AgentCore never reads, wraps, or changes the brief; the host writes all of it. + /// + internal sealed class CallBriefHook : AgentHook + { + /// + /// The one line a call whose vendor speaks for itself adds to every run, so the agent reads the + /// lines in its history as already heard. + /// + internal const string FrontVoiceNote = + $"Messages from {FrontVoice.AuthorName} are what the phone's front voice already said to the caller: answer only the caller's latest message, and correct a {FrontVoice.AuthorName} line only if it was wrong."; + + private readonly ConcurrentDictionary _briefs = new(StringComparer.Ordinal); + + /// Files the brief of the call that runs on . + /// The conversation the call runs on. + /// The brief, added to every run verbatim, or for none. + /// Whether the call's vendor speaks for itself, so every run also gets . + internal void Set(string conversationId, string? brief, bool frontVoice = false) + { + ArgumentException.ThrowIfNullOrEmpty(conversationId); + _briefs[conversationId] = (brief, frontVoice); + } + + /// Forgets the brief: the call ended. + /// The conversation the call ran on. + internal void Clear(string conversationId) + { + ArgumentNullException.ThrowIfNull(conversationId); + _ = _briefs.TryRemove(conversationId, out _); + } + + /// + public override ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(gate); + + if (gate.Scope.ConversationId is { } conversationId && _briefs.TryGetValue(conversationId, out (string? Brief, bool FrontVoice) call)) + { + if (call.Brief is { } brief) + { + gate.AddInstructions(brief); + } + + if (call.FrontVoice) + { + gate.AddInstructions(FrontVoiceNote); + } + } + + return default; + } + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs new file mode 100644 index 00000000..bbdb6ba9 --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs @@ -0,0 +1,85 @@ +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Hooks.BuiltIn +{ + /// + /// The "log once" failure lines, read from notices. It never writes what the caller or the agent said. + /// + /// Where the lines go, or for nowhere. + internal sealed class LoggingHook(ILogger? logger) : AgentHook + { + /// The turn a line names when the notice carried no index. No turn has it. + private const int NoTurn = -1; + + private readonly ILogger _logger = logger ?? NullLogger.Instance; + + /// + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) + { + Log.TurnRefused(_logger, IdOf(notice), TurnOf(notice), TurnRefusalTokens.ToToken(notice.Reason)); + return ValueTask.CompletedTask; + } + + /// + public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) + { + switch (notice.Verdict) + { + case InputVerdict.Flagged: + Log.PromptRefused(_logger, IdOf(notice), TurnOf(notice), string.Join(',', notice.Categories)); + break; + case InputVerdict.Unavailable: + Log.ModerationUnavailable(_logger, IdOf(notice), TurnOf(notice), notice.Reason == ModerationUnavailableReason.TimedOut + ? TurnFailureReasons.ModerationTimedOut + : TurnFailureReasons.ModerationFaulted); + break; + case InputVerdict.Clean: + default: + break; + } + + return ValueTask.CompletedTask; + } + + /// + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + TurnFailureAccounting.Write( + _logger, IdOf(notice), TurnOf(notice), TurnFailureAccounting.Of(notice.Outcome, notice.FailedInTool), notice.Cause); + return ValueTask.CompletedTask; + } + + /// + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) + { + switch (notice) + { + case { Kind: FaultKind.ExtractionFailed }: + Log.ExtractionFailed(_logger, IdOf(notice), TurnOf(notice), notice.Message); + break; + case { Kind: FaultKind.StateRestorePartial }: + Log.StateRestorePartial(_logger, IdOf(notice), notice.Message); + break; + case { Kind: FaultKind.TranscriptWriteFailed, Cause: { } cause }: + Log.TranscriptWriteFailed(_logger, IdOf(notice), TurnOf(notice), cause); + break; + case { Kind: FaultKind.TranscriptResyncFailed, Cause: { } cause }: + Log.TranscriptResyncFailed(_logger, IdOf(notice), TurnOf(notice), cause); + break; + default: + // HookFailed is logged by the engine itself; the busy-mark faults keep their own direct lines. + break; + } + + return ValueTask.CompletedTask; + } + + private static string IdOf(HookNotice notice) => notice.Scope.ConversationId ?? string.Empty; + + private static int TurnOf(HookNotice notice) => notice.Scope.TurnIndex ?? NoTurn; + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs new file mode 100644 index 00000000..8ef20414 --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs @@ -0,0 +1,100 @@ +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Hooks.BuiltIn +{ + /// + /// Today's counters, read from notices: failures, moderation verdicts, and the audit rows the chain writes. + /// Spans stay inline: an OTel activity must flow with the call. + /// + internal sealed class TelemetryHook : AgentHook + { + /// + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => + Audited(AuditEventKind.ConversationStarted); + + /// + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => + Audited(AuditEventKind.ConversationEnded); + + /// + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => + AuditRules.IsAudited(notice) ? Audited(AuditEventKind.TurnRefused) : ValueTask.CompletedTask; + + /// + public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => + Audited(AuditEventKind.TurnSuperseded); + + /// + public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => + Audited(AuditEventKind.ReplyInterrupted); + + /// + public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) + { + AgentCoreTelemetry.RecordModeration(notice.Verdict switch + { + InputVerdict.Flagged => AgentCoreTelemetry.ModerationFlagged, + InputVerdict.Unavailable => AgentCoreTelemetry.ModerationUnavailable, + InputVerdict.Clean => AgentCoreTelemetry.ModerationClean, + _ => throw new ArgumentOutOfRangeException(nameof(notice), notice.Verdict, "The verdict is outside the closed set."), + }); + + return AuditRules.IsAudited(notice) ? Audited(AuditEventKind.PromptFlagged) : ValueTask.CompletedTask; + } + + /// + public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => + AuditRules.IsAudited(notice) ? Audited(AuditEventKind.ToolFailed) : ValueTask.CompletedTask; + + /// + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + TurnFailureAccounting.Count(TurnFailureAccounting.Of(notice.Outcome, notice.FailedInTool)); + + return Audited(AuditEventKind.TurnCompleted); + } + + /// + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) + { + if (notice.Kind == FaultKind.ExtractionFailed) + { + AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureExtraction); + } + + return ValueTask.CompletedTask; + } + + /// + public override ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) + { + switch (notice.Metric) + { + case LatencyMetric.TimeToFirstToken: + AgentCoreTelemetry.RecordTimeToFirstToken(notice.Value); + break; + case LatencyMetric.TimeToFirstSpeech: + AgentCoreTelemetry.RecordTimeToFirstSpeech(notice.Value); + break; + case LatencyMetric.TimeToReplyEnd: + AgentCoreTelemetry.RecordTimeToReplyEnd(notice.Value); + break; + case LatencyMetric.BargeIn: + AgentCoreTelemetry.RecordBargeInLatency(notice.Value); + break; + default: + break; + } + + return ValueTask.CompletedTask; + } + + private static ValueTask Audited(AuditEventKind kind) + { + AgentCoreTelemetry.RecordAuditEvent(AuditEventKinds.ToToken(kind)); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs b/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs new file mode 100644 index 00000000..538c5001 --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs @@ -0,0 +1,102 @@ +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Hooks.BuiltIn +{ + /// + /// Which failure a turn met, the counter it bumps and the line it logs. The telemetry hook and + /// the logging hook read it from ; a turn the store refused raises no such notice, so + /// the session calls for it. One mapping, so a counter and a line never disagree. + /// + internal static class TurnFailureAccounting + { + /// The failure row a turn met. + internal enum Row + { + /// The turn met none. + None, + + /// The run returned no text. + EmptyReply, + + /// A tool spent its retry budget. + ToolBudget, + + /// The run faulted above the fallback layer. + RunFault, + } + + /// Reads the row a turn met. + /// How the turn ended. + /// Whether the fallback came from a tool that spent its retry budget. + /// The row, or . + internal static Row Of(TurnOutcome outcome, bool failedInTool) => (outcome, failedInTool) switch + { + (TurnOutcome.Empty, _) => Row.EmptyReply, + (TurnOutcome.Fallback, true) => Row.ToolBudget, + (TurnOutcome.Fallback or TurnOutcome.Faulted, _) => Row.RunFault, + _ => Row.None, + }; + + /// Counts the row on agentcore.turn.failures. + /// The row. + internal static void Count(Row row) + { + string? kind = row switch + { + Row.EmptyReply => AgentCoreTelemetry.FailureEmptyReply, + Row.ToolBudget => AgentCoreTelemetry.FailureTool, + Row.RunFault => AgentCoreTelemetry.FailureRun, + Row.None => null, + _ => throw new ArgumentOutOfRangeException(nameof(row), row, "The row is outside the closed set."), + }; + + if (kind is not null) + { + AgentCoreTelemetry.RecordFailure(kind); + } + } + + /// Writes the row's line. A tool or run row with no exception writes nothing: the line is its stack trace. + /// Where the line goes. + /// The conversation. + /// The turn. + /// The row. + /// The exception behind the failure, or . + internal static void Write(ILogger logger, string conversationId, int turnIndex, Row row, Exception? cause) + { + switch (row) + { + case Row.EmptyReply: + Log.EmptyReply(logger, conversationId, turnIndex); + break; + case Row.ToolBudget when cause is not null: + Log.ToolBudgetSpent(logger, conversationId, turnIndex, cause); + break; + case Row.RunFault when cause is not null: + Log.TurnRunFaulted(logger, conversationId, turnIndex, cause); + break; + case Row.None: + case Row.ToolBudget: + case Row.RunFault: + default: + break; + } + } + + /// Counts and logs the row of a turn the store refused, which no hook hears about. + /// Where the line goes. + /// The conversation. + /// The turn. + /// How the turn ended. + /// Whether the fallback came from a tool that spent its retry budget. + /// The exception behind the failure, or . + internal static void Account(ILogger logger, string conversationId, int turnIndex, TurnOutcome outcome, bool failedInTool, Exception? cause) + { + Row row = Of(outcome, failedInTool); + Count(row); + Write(logger, conversationId, turnIndex, row, cause); + } + } +} diff --git a/src/AgentCore.Application/Hooks/BuiltInHooks.cs b/src/AgentCore.Application/Hooks/BuiltInHooks.cs new file mode 100644 index 00000000..1616a57d --- /dev/null +++ b/src/AgentCore.Application/Hooks/BuiltInHooks.cs @@ -0,0 +1,22 @@ +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Ports; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AgentCore.Application.Hooks +{ + /// AgentCore's own hooks, for a host that builds sessions without ASP.NET. They run first. + public static class BuiltInHooks + { + /// Creates the built-in hooks in the order they run. + /// Where the audit chain is appended. + /// Where the built-ins report, such as the "log once" lines and a row the sink refused, or for nowhere. + /// The hooks; put them first in AgentCompilationContext.Hooks. + /// is . + public static IReadOnlyList Create(IAuditSinkPort auditSink, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(auditSink); + return [new TelemetryHook(), new LoggingHook(logger), new AuditHook(auditSink, logger ?? NullLogger.Instance)]; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs b/src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs new file mode 100644 index 00000000..ebd64c75 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs @@ -0,0 +1,7 @@ +namespace AgentCore.Application.Hooks.Engine +{ + /// What the BeforeToolApproval hook chain decided about one tool call. + /// approved, denied, left to the patterns or a human. + /// The denial's reason, or . + internal sealed record ApprovalDecision(bool? Approved, string? Reason); +} diff --git a/src/AgentCore.Application/Hooks/Engine/Delivery.cs b/src/AgentCore.Application/Hooks/Engine/Delivery.cs new file mode 100644 index 00000000..2fdc1160 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/Delivery.cs @@ -0,0 +1,7 @@ +namespace AgentCore.Application.Hooks.Engine +{ + /// One item of a hook's channel: a notice, or a flush barrier that completes when the reader reaches it. + /// The notice to hand the hook, or for a barrier. + /// Completed when the reader reaches this item, or for a notice. + internal readonly record struct Delivery(HookNotice? Notice, TaskCompletionSource? Barrier); +} diff --git a/src/AgentCore.Application/Hooks/Engine/GateRunner.cs b/src/AgentCore.Application/Hooks/Engine/GateRunner.cs new file mode 100644 index 00000000..92d0cc13 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/GateRunner.cs @@ -0,0 +1,178 @@ +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Hooks.Engine +{ + /// Runs one gate's chain: each overriding hook in order, each under the gate's deadline. + /// Which hooks override which gate. + /// Where a failing hook is reported. + /// The clock the deadlines run on. + internal sealed class GateRunner(HookTable table, ILogger logger, TimeProvider timers) + { + internal HookTable Table => table; + + internal bool Overrides(GatePoint point) => table.Overrides(point); + + /// Runs the chain and returns the value the hooks left. + /// The gate's view type. + /// The value the chain decides. + /// The gate. + /// Where and when it fires. + /// The value before any hook. + /// Builds one hook's view from the value so far. + /// Calls the hook's gate method. + /// Applies one view's staged verbs to the value. Called only for a hook that returned in time, after its view is sealed. + /// Applies the gate's safe verb, for a hook that fails closed. + /// Raises Fault(HookFailed) to every hook but the failing one, or . + /// The turn's token. Its cancellation propagates and is not a hook failure. + /// How long each hook may take, or for 's own deadline. + /// The value after the last hook that ran. + internal async ValueTask RunAsync( + GatePoint point, + HookScope scope, + TState state, + Func open, + Func invoke, + Func fold, + Func failClosed, + Action? raiseFault, + CancellationToken cancellationToken, + TimeSpan? deadline = null) + where TGate : HookGate + { + TimeSpan limit = deadline ?? point.Deadline; + foreach (AgentHook hook in table.For(point)) + { + TGate view = open(state); + HookRun run; + try + { + run = await InvokeAsync(hook, view, invoke, limit, cancellationToken).ConfigureAwait(false); + } + finally + { + view.Seal(); + } + + if (run.Failure is null) + { + state = fold(state, view); + if (view.IsTerminal) + { + break; + } + + continue; + } + + Report(point, hook, scope, run, limit, raiseFault); + + if (FailureOf(hook, point) == HookFailure.Closed) + { + return failClosed(state); + } + } + + return state; + } + + private static HookFailure FailureOf(AgentHook hook, GatePoint point) + { + try + { + return hook.FailureFor(point); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return point.DefaultFailure; + } + } + + // A hook's own TimeoutException faulted its task; the wait's own timeout is a fresh exception. + private static bool ThrownBy(Task running, Exception exception) => + running.Exception?.InnerExceptions.Contains(exception) == true; + + // Disposing a source unhooks it from the turn token and stops its timer, so a hook still running would + // never see its token cancelled. The sources live until the hook ends, however long that is. + private static void ReleaseWhenDone(Task running, CancellationTokenSource deadline, CancellationTokenSource linked) + { + if (running.IsCompleted) + { + linked.Dispose(); + deadline.Dispose(); + return; + } + + _ = running.ContinueWith( + task => + { + _ = task.Exception; + linked.Dispose(); + deadline.Dispose(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private async ValueTask InvokeAsync( + AgentHook hook, + TGate view, + Func invoke, + TimeSpan limit, + CancellationToken cancellationToken) + { + CancellationTokenSource deadline = new(limit, timers); + CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token); + + // The hook starts on the pool, so one that blocks its thread before its first await still + // misses the deadline instead of holding the turn. + Task running = Task.Run(() => invoke(hook, view, linked.Token).AsTask(), CancellationToken.None); + try + { + await running.WaitAsync(limit, timers, cancellationToken).ConfigureAwait(false); + return HookRun.Completed; + } + catch (TimeoutException exception) when (!ThrownBy(running, exception)) + { + return new HookRun(exception, Abandoned: true); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + return new HookRun(exception, Abandoned: false); + } + finally + { + ReleaseWhenDone(running, deadline, linked); + } + } + + private void Report(GatePoint point, AgentHook hook, HookScope scope, HookRun run, TimeSpan limit, Action? raiseFault) + { + Exception failure = run.Failure!; + string hookType = hook.GetType().FullName ?? hook.GetType().Name; + string conversationId = scope.ConversationId ?? string.Empty; + + if (run.Abandoned) + { + HookLog.GateAbandoned(logger, hookType, point.Name, conversationId, (long)limit.TotalMilliseconds); + } + else + { + HookLog.GateFailed(logger, hookType, point.Name, conversationId, failure); + } + + raiseFault?.Invoke( + new Fault(scope, FaultKind.HookFailed, $"{hookType} failed at gate {point.Name}: {failure.GetType().Name}: {failure.Message}", failure), + hook); + } + + /// How one hook's call ended. + /// What went wrong, or when the hook returned in time. + /// Whether the hook missed the deadline, rather than threw in time. + private readonly record struct HookRun(Exception? Failure, bool Abandoned) + { + public static HookRun Completed => default; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/HookLog.cs b/src/AgentCore.Application/Hooks/Engine/HookLog.cs new file mode 100644 index 00000000..671a8896 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/HookLog.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Hooks.Engine +{ + /// Every line the hook engine writes. Each names the hook type and the gate or notice. + internal static partial class HookLog + { + /// A gate hook threw. + /// The engine's logger. + /// The full name of the hook's type. + /// The gate point's name. + /// The id of the conversation, empty before one exists. + /// What the hook threw. + [LoggerMessage( + EventId = 200, + Level = LogLevel.Warning, + Message = "The hook {HookType} failed at gate {Gate} of conversation {ConversationId}. Its verbs were dropped.")] + public static partial void GateFailed(ILogger logger, string hookType, string gate, string conversationId, Exception exception); + + /// A gate hook missed its deadline and was abandoned. + /// The engine's logger. + /// The full name of the hook's type. + /// The gate point's name. + /// The id of the conversation, empty before one exists. + /// The gate's deadline, in milliseconds. + [LoggerMessage( + EventId = 201, + Level = LogLevel.Warning, + Message = "The hook {HookType} missed the {DeadlineMs} ms deadline of gate {Gate} of conversation {ConversationId}. It was abandoned and its verbs were dropped.")] + public static partial void GateAbandoned(ILogger logger, string hookType, string gate, string conversationId, long deadlineMs); + + /// A notice hook threw. + /// The engine's logger. + /// The full name of the hook's type. + /// The notice's type name. + /// The id of the conversation, empty for a host notice. + /// What the hook threw. + [LoggerMessage( + EventId = 202, + Level = LogLevel.Warning, + Message = "The hook {HookType} failed on the notice {NoticeType} of conversation {ConversationId}.")] + public static partial void NoticeFailed(ILogger logger, string hookType, string noticeType, string conversationId, Exception exception); + + /// A notice hook passed its NoticeTimeout and the delivery was abandoned. + /// The engine's logger. + /// The full name of the hook's type. + /// The notice's type name. + /// The id of the conversation, empty for a host notice. + /// The hook's notice timeout, in milliseconds. + [LoggerMessage( + EventId = 203, + Level = LogLevel.Warning, + Message = "The hook {HookType} did not finish the notice {NoticeType} of conversation {ConversationId} within {TimeoutMs} ms. The delivery was abandoned.")] + public static partial void NoticeAbandoned(ILogger logger, string hookType, string noticeType, string conversationId, long timeoutMs); + + /// A notice hook with no timeout is still working on one notice. + /// The engine's logger. + /// The full name of the hook's type. + /// The notice's type name. + /// The id of the conversation, empty for a host notice. + [LoggerMessage( + EventId = 204, + Level = LogLevel.Warning, + Message = "The hook {HookType} is still working on the notice {NoticeType} of conversation {ConversationId}. Its later notices wait behind it.")] + public static partial void NoticeStillWaiting(ILogger logger, string hookType, string noticeType, string conversationId); + + /// The host stopped before every notice was delivered. + /// The engine's logger. + /// How many mailboxes were still draining. + /// How long the stop waited, in milliseconds. + [LoggerMessage( + EventId = 205, + Level = LogLevel.Warning, + Message = "The host stopped before the hooks of {Mailboxes} conversations finished their notices; it waited {TimeoutMs} ms.")] + public static partial void DrainTimedOut(ILogger logger, int mailboxes, long timeoutMs); + + /// The engine itself failed while it delivered a notice; the hook's reader goes on with the next one. + /// The engine's logger. + /// The full name of the hook's type. + /// The notice's type name. + /// The id of the conversation, empty for a host notice. + /// What went wrong. + [LoggerMessage( + EventId = 206, + Level = LogLevel.Error, + Message = "AgentCore failed to deliver the notice {NoticeType} of conversation {ConversationId} to the hook {HookType}. The hook's later notices still go out.")] + public static partial void DeliveryFailed(ILogger logger, string hookType, string noticeType, string conversationId, Exception exception); + + /// A hook kept denying tool calls past the approval layer's round cap, so the turn ends as a fault. + /// The engine's logger. + /// The id of the conversation. + /// The turn that ended. + /// How many model rounds of denials the layer answered. + [LoggerMessage( + EventId = 207, + Level = LogLevel.Warning, + Message = "A hook denied tool calls of turn {TurnIndex} of conversation {ConversationId} after {MaxRounds} model rounds of denials. The turn ends as a fault, and no person is asked.")] + public static partial void DenialRoundsExhausted(ILogger logger, string conversationId, int turnIndex, int maxRounds); + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/HookReader.cs b/src/AgentCore.Application/Hooks/Engine/HookReader.cs new file mode 100644 index 00000000..a03a7d1a --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/HookReader.cs @@ -0,0 +1,295 @@ +using System.Threading.Channels; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks.Engine +{ + /// + /// One hook's queue inside one mailbox, and the task that reads it. Notices reach the hook one at a time, in + /// the order the mailbox wrote them. + /// + internal sealed class HookReader + { + /// The longest delay a timer takes: uint.MaxValue - 1 ms, about 49.7 days. + internal static readonly TimeSpan LongestTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + private readonly Channel _channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + private readonly Mailbox _box; + + private readonly IReadOnlySet _wants; + + private readonly TimeSpan? _timeout; + + private Task? _reading; + + private int _pending; + + internal HookReader(Mailbox box, AgentHook hook, IReadOnlySet wants) + { + _box = box; + Hook = hook; + _wants = wants; + _timeout = TimeoutOf(hook); + } + + internal AgentHook Hook { get; } + + internal Task Completion => Volatile.Read(ref _reading) ?? Task.CompletedTask; + + /// Gets whether a notice was ever written here, which starts the reading task. + internal bool Started => Volatile.Read(ref _reading) is not null; + + internal bool IsIdle => Volatile.Read(ref _pending) == 0; + + internal bool Wants(HookNotice notice) => _wants.Contains(notice.GetType()); + + /// Queues one notice. Runs under the mailbox lock, so queue order is Sequence order. + internal void Write(HookNotice notice) + { + _ = Interlocked.Increment(ref _pending); + if (!_channel.Writer.TryWrite(new Delivery(notice, null))) + { + _ = Interlocked.Decrement(ref _pending); + return; + } + + if (_reading is null) + { + Volatile.Write(ref _reading, StartReading()); + } + } + + /// Queues a barrier. Runs under the mailbox lock. Completes once every earlier notice was handled. + internal Task Barrier() + { + if (_reading is null) + { + return Task.CompletedTask; + } + + TaskCompletionSource reached = new(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Interlocked.Increment(ref _pending); + if (_channel.Writer.TryWrite(new Delivery(null, reached))) + { + return reached.Task; + } + + _ = Interlocked.Decrement(ref _pending); + return Task.CompletedTask; + } + + internal void Complete() + { + _ = _channel.Writer.TryComplete(); + } + + // A timer refuses a negative delay or one past LongestTimeout. Longer than a timer can wait means + // "wait for every notice", which is what null says; a negative one is a mistake and gets the default. + private static TimeSpan? TimeoutOf(AgentHook hook) + { + TimeSpan? timeout; + try + { + timeout = hook.NoticeTimeout; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return DefaultTimeout; + } + + return timeout switch + { + null => null, + { } value when value == Timeout.InfiniteTimeSpan || value > LongestTimeout => null, + { } value when value < TimeSpan.Zero => DefaultTimeout, + _ => timeout, + }; + } + + private static string NameOf(AgentHook hook) => hook.GetType().FullName ?? hook.GetType().Name; + + // The reader lives as long as the mailbox. Started with the raiser's context, every later delivery would + // run inside the first turn's trace, logging scopes and run context, and keep them alive. + private Task StartReading() + { + bool restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + _ = ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + return Task.Run(ReadAsync); + } + finally + { + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + + private async Task ReadAsync() + { + ChannelReader reader = _channel.Reader; + while (true) + { + bool more; + using (CancellationTokenSource idle = new(NoticeHub.IdleAfter, _box.Hub.Timers)) + { + try + { + more = await reader.WaitToReadAsync(idle.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (idle.IsCancellationRequested) + { + // An evicted mailbox completes this channel, so the next wait returns false and the loop ends. + _ = _box.TryEvict(); + continue; + } + } + + if (!more) + { + return; + } + + while (reader.TryRead(out Delivery delivery)) + { + await HandleAsync(delivery).ConfigureAwait(false); + } + } + } + + // A reader that died would leave its channel growing, its mailbox never evicted, and every flush waiting. + private async Task HandleAsync(Delivery delivery) + { + try + { + if (delivery.Barrier is { } barrier) + { + _ = barrier.TrySetResult(); + } + else + { + await DeliverAsync(delivery.Notice!).ConfigureAwait(false); + } + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + EngineFailed(delivery.Notice, exception); + } + finally + { + _ = Interlocked.Decrement(ref _pending); + } + } + + private async Task DeliverAsync(HookNotice notice) + { + using CancellationTokenSource deadline = _timeout is { } limit ? new(limit, _box.Hub.Timers) : new(); + + Task running; + try + { + running = NoticeDispatch.DeliverAsync(Hook, notice, deadline.Token).AsTask(); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + Failed(notice, exception); + return; + } + + if (_timeout is { } timeout) + { + await WithinAsync(notice, running, timeout, deadline).ConfigureAwait(false); + } + else + { + await PatientlyAsync(notice, running).ConfigureAwait(false); + } + } + + private async Task WithinAsync(HookNotice notice, Task running, TimeSpan timeout, CancellationTokenSource deadline) + { + try + { + await running.WaitAsync(timeout, _box.Hub.Timers).ConfigureAwait(false); + } + // A hook that honours its token gives up at the deadline too; that is the same missed deadline. + catch (Exception exception) when ((exception is TimeoutException && !running.IsCompleted) + || (exception is OperationCanceledException && deadline.IsCancellationRequested)) + { + HookLog.NoticeAbandoned(_box.Hub.Logger, NameOf(Hook), notice.GetType().Name, notice.Scope.ConversationId ?? string.Empty, (long)timeout.TotalMilliseconds); + _ = running.ContinueWith(static task => task.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + RaiseFault(notice, $"{NameOf(Hook)} did not finish {notice.GetType().Name} within {timeout.TotalMilliseconds:0} ms.", cause: null); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + Failed(notice, exception); + } + } + + private async Task PatientlyAsync(HookNotice notice, Task running) + { + bool reported = false; + while (true) + { + try + { + await running.WaitAsync(NoticeHub.StillWaitingEvery, _box.Hub.Timers).ConfigureAwait(false); + return; + } + catch (TimeoutException) when (!running.IsCompleted) + { + HookLog.NoticeStillWaiting(_box.Hub.Logger, NameOf(Hook), notice.GetType().Name, notice.Scope.ConversationId ?? string.Empty); + if (!reported) + { + reported = true; + RaiseFault(notice, $"{NameOf(Hook)} is still working on {notice.GetType().Name}.", cause: null); + } + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + Failed(notice, exception); + return; + } + } + } + + private void EngineFailed(HookNotice? notice, Exception exception) + { + try + { + HookLog.DeliveryFailed(_box.Hub.Logger, NameOf(Hook), notice?.GetType().Name ?? string.Empty, notice?.Scope.ConversationId ?? string.Empty, exception); + } + catch (Exception logFailure) when (logFailure is not OutOfMemoryException) + { + // The logger itself throws. Nothing is left to report to, and the reader must go on. + _ = logFailure; + } + } + + private void Failed(HookNotice notice, Exception exception) + { + HookLog.NoticeFailed(_box.Hub.Logger, NameOf(Hook), notice.GetType().Name, notice.Scope.ConversationId ?? string.Empty, exception); + RaiseFault(notice, $"{NameOf(Hook)} failed on {notice.GetType().Name}: {exception.GetType().Name}: {exception.Message}", exception); + } + + private void RaiseFault(HookNotice notice, string message, Exception? cause) + { + // Two hooks that both throw on faults would otherwise trade HookFailed notices forever. + if (notice is Fault { Kind: FaultKind.HookFailed }) + { + return; + } + + _ = _box.Hub.Raise(new Fault(notice.Scope, FaultKind.HookFailed, message, cause), _box.Hub.Timers, except: Hook); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs b/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs new file mode 100644 index 00000000..880483c1 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs @@ -0,0 +1,82 @@ +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AgentCore.Application.Hooks.Engine +{ + /// The hooks of one compile: which hook overrides what, the gate runner, and the notice hub. + internal sealed class HookRuntime : IAsyncDisposable + { + /// How long a stop waits for queued notices when the host names no shutdown timeout. + internal static readonly TimeSpan DefaultStopTimeout = TimeSpan.FromSeconds(30); + + private HookRuntime(HookTable gates, NoticeHub notices, ILogger logger, TimeProvider timers) + { + Gates = new GateRunner(gates, logger, timers); + Notices = notices; + Logger = logger; + Timers = timers; + } + + internal static HookRuntime Empty { get; } = Create([], loggers: null); + + internal HookTable Table => Gates.Table; + + internal GateRunner Gates { get; } + + internal NoticeHub Notices { get; } + + internal ILogger Logger { get; } + + internal TimeProvider Timers { get; } + + /// Gets or sets how long a dispose waits for queued notices: the host's shutdown timeout. + internal TimeSpan StopTimeout { get; set; } = DefaultStopTimeout; + + /// Builds the runtime of one compile. + /// The hooks, built-ins first. + /// Where the engine logs, or . + /// The clock of the deadlines and timeouts. Production passes nothing: . + internal static HookRuntime Create(IReadOnlyList hooks, ILoggerFactory? loggers, TimeProvider? timers = null) + { + HookTable table = HookTable.Build(hooks); + ILogger logger = loggers?.CreateLogger("AgentCore.Hooks") ?? NullLogger.Instance; + TimeProvider clock = timers ?? TimeProvider.System; + return new HookRuntime(table, new NoticeHub(table, logger, clock), logger, clock); + } + + /// Builds the runtime of a factory with hooks of its own: the same gates, a notice hub over both sets. + /// A hook overrides a gate and was not compiled in. + internal HookRuntime WithNoticeHooks(IEnumerable hooks) + { + ArgumentNullException.ThrowIfNull(hooks); + + AgentHook[] added = [.. hooks]; + foreach (AgentHook hook in added) + { + ArgumentNullException.ThrowIfNull(hook, nameof(hooks)); + if (!Table.Hooks.Contains(hook, ReferenceEqualityComparer.Instance) && HookTable.Build([hook]).OverridesAnyGate(hook)) + { + throw new ArgumentException( + $"The hook '{hook.GetType().FullName}' overrides a gate, and gates are compiled into the agent. " + + $"Pass it in {nameof(Configuration.Compilation.AgentCompilationContext)}.{nameof(Configuration.Compilation.AgentCompilationContext.Hooks)}.", + nameof(hooks)); + } + } + + HookTable notices = HookTable.Build([.. Table.Hooks, .. added]); + return new HookRuntime(Table, new NoticeHub(notices, Logger, Timers), Logger, Timers); + } + + /// Raises a gate's Fault(HookFailed) where no session is open (BeforeCall, BeforeEntry). + internal void RaiseHostFault(Fault fault, AgentHook except) + { + _ = Notices.Raise(fault, Timers, except: except); + } + + internal ValueTask StopAsync(TimeSpan timeout) => Notices.StopAsync(timeout); + + /// + public ValueTask DisposeAsync() => StopAsync(StopTimeout); + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/HookScopes.cs b/src/AgentCore.Application/Hooks/Engine/HookScopes.cs new file mode 100644 index 00000000..78649038 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/HookScopes.cs @@ -0,0 +1,11 @@ +namespace AgentCore.Application.Hooks.Engine +{ + /// Scopes for the gates and notices that have no session. + internal static class HookScopes + { + internal static HookScope Host(DateTimeOffset now) => new(null, null, null, null, Guid.Empty, 0, now); + + internal static HookScope ForGate(string? conversationId, string? entry, DateTimeOffset now) => + new(conversationId, entry, null, null, Guid.Empty, 0, now); + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/HookTable.cs b/src/AgentCore.Application/Hooks/Engine/HookTable.cs new file mode 100644 index 00000000..acce6e8a --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/HookTable.cs @@ -0,0 +1,115 @@ +using System.Reflection; + +namespace AgentCore.Application.Hooks.Engine +{ + /// + /// What each registered hook overrides, found once by reflection. A method no hook overrides + /// has an empty array here, so its gate or notice costs one length check. + /// + internal sealed class HookTable + { + private static readonly MethodInfo[] NoticeMethods = [.. typeof(AgentHook) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(static method => method.IsVirtual + && method.GetParameters() is [{ ParameterType: var type }, _] + && typeof(HookNotice).IsAssignableFrom(type))]; + + private static readonly IReadOnlySet NoNotices = new HashSet(); + + private readonly Dictionary _gates; + + private readonly Dictionary> _notices; + + private readonly HashSet _wanted; + + private HookTable(IReadOnlyList hooks) + { + Hooks = hooks; + _gates = GatePoint.All.ToDictionary( + static point => point, + point => hooks.Where(hook => Overrides(hook, typeof(AgentHook).GetMethod(point.MethodName)!)).ToArray()); + _notices = new Dictionary>(ReferenceEqualityComparer.Instance); + + foreach (AgentHook hook in hooks) + { + HashSet types = [.. NoticeMethods + .Where(method => Overrides(hook, method)) + .Select(static method => method.GetParameters()[0].ParameterType)]; + + if (types.Count > 0) + { + _notices[hook] = types; + } + } + + NoticeHooks = [.. hooks.Where(_notices.ContainsKey)]; + _wanted = [.. _notices.Values.SelectMany(static types => types)]; + } + + /// Gets a table with no hooks. + internal static HookTable Empty { get; } = new([]); + + /// Gets every hook, in the order they run: AgentCore's built-ins first, then the host's. + internal IReadOnlyList Hooks { get; } + + /// Gets the hooks that override at least one notice method, in run order. + internal IReadOnlyList NoticeHooks { get; } + + /// Builds the table. An instance listed twice counts once, at its first place. + /// The hooks in run order. + /// The table. + internal static HookTable Build(IReadOnlyList hooks) + { + ArgumentNullException.ThrowIfNull(hooks); + + List distinct = []; + HashSet seen = new(ReferenceEqualityComparer.Instance); + foreach (AgentHook hook in hooks) + { + ArgumentNullException.ThrowIfNull(hook, nameof(hooks)); + if (seen.Add(hook)) + { + distinct.Add(hook); + } + } + + return new HookTable(distinct); + } + + internal AgentHook[] For(GatePoint point) => _gates[point]; + + internal bool Overrides(GatePoint point) => _gates[point].Length > 0; + + internal IReadOnlySet NoticesOf(AgentHook hook) => + _notices.TryGetValue(hook, out IReadOnlySet? types) ? types : NoNotices; + + internal bool Wants(Type noticeType) => _wanted.Contains(noticeType); + + internal bool OverridesAnyGate(AgentHook hook) => + GatePoint.All.Any(point => Array.Exists(_gates[point], listed => ReferenceEquals(listed, hook))); + + /// Whether a hook type overrides one gate, read from the type alone. + /// The hook's type. + /// The gate. + /// when the type overrides the gate's method. + internal static bool TypeOverrides(Type hookType, GatePoint point) + { + return TypeOverrides(hookType, typeof(AgentHook).GetMethod(point.MethodName)!); + } + + private static bool Overrides(AgentHook hook, MethodInfo baseMethod) => TypeOverrides(hook.GetType(), baseMethod); + + // GetBaseDefinition tells an override apart from a method hidden with `new`, which the base dispatch never calls. + private static bool TypeOverrides(Type hookType, MethodInfo baseMethod) + { + MethodInfo? found = hookType.GetMethod( + baseMethod.Name, + BindingFlags.Public | BindingFlags.Instance, + [.. baseMethod.GetParameters().Select(static parameter => parameter.ParameterType)]); + + return found is not null + && found.DeclaringType != typeof(AgentHook) + && found.GetBaseDefinition() == baseMethod; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/Mailbox.cs b/src/AgentCore.Application/Hooks/Engine/Mailbox.cs new file mode 100644 index 00000000..19d64322 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/Mailbox.cs @@ -0,0 +1,205 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks.Engine +{ + /// + /// The notices of one conversation id, or of the host. Its private lock makes the Sequence count and the + /// channel writes one step, so Sequence order is delivery order. No turn code holds this lock, + /// and no hook code runs under it. + /// + internal sealed class Mailbox + { + private readonly Lock _lock = new(); + + private readonly string? _conversationId; + + private readonly HookReader[] _readers; + + private long _sequence; + + private Guid _lastSession; + + private (Guid Session, int LastTurn)? _ended; + + private int _pins; + + private bool _closed; + + internal Mailbox(NoticeHub hub, string? conversationId) + { + Hub = hub; + _conversationId = conversationId; + _readers = [.. hub.Table.NoticeHooks.Select(hook => new HookReader(this, hook, hub.Table.NoticesOf(hook)))]; + } + + internal NoticeHub Hub { get; } + + internal IEnumerable Readers => _readers.Select(static reader => reader.Completion); + + /// Stamps and queues one notice. + /// when this mailbox was closed and the caller must look up a new one. + internal bool TryRaise( + HookNotice notice, TimeProvider clock, int? endsAfterTurn, AgentHook? except, DateTimeOffset? occurredAt, out Guid eventId) + { + // A version 7 Guid reads the wall clock, which can cost microseconds; its random bits never sort + // within one millisecond anyway, so it is made before the lock and only stamped under it. + eventId = Guid.CreateVersion7(); + lock (_lock) + { + if (_closed) + { + return false; + } + + HookScope scope = notice.Scope; + if (HeldBackByTheEnd(notice)) + { + return true; + } + + HookNotice stamped = notice with + { + Scope = scope with { Sequence = ++_sequence, OccurredAt = occurredAt ?? clock.GetUtcNow() }, + EventId = eventId, + }; + + if (scope.SessionId != Guid.Empty) + { + _lastSession = scope.SessionId; + } + + if (stamped is ConversationEnded) + { + _ended = (scope.SessionId, endsAfterTurn ?? -1); + } + + foreach (HookReader reader in _readers) + { + if (!ReferenceEquals(reader.Hook, except) && reader.Wants(stamped)) + { + reader.Write(stamped); + } + } + + return true; + } + } + + /// Records an end no hook hears, so the ended rule still holds back the session's later turns. + internal void MarkEnded(Guid sessionId, int? endsAfterTurn) + { + lock (_lock) + { + _ended = (sessionId, endsAfterTurn ?? -1); + } + } + + internal bool LoadedBefore(Guid sessionId) + { + lock (_lock) + { + return !_closed && _lastSession != Guid.Empty && _lastSession != sessionId; + } + } + + internal Task FlushAsync() + { + lock (_lock) + { + return _closed ? Task.CompletedTask : Task.WhenAll(_readers.Select(static reader => reader.Barrier())); + } + } + + /// Keeps this mailbox while a session is loaded, so its Sequence does not restart. + /// when this mailbox was closed and the caller must look up a new one. + internal bool TryPin() + { + lock (_lock) + { + if (_closed) + { + return false; + } + + _pins++; + return true; + } + } + + /// Lets go of one pin. A mailbox no notice ever reached has no reader to evict it, so the last release does. + internal void Release() + { + lock (_lock) + { + if (_pins == 0) + { + return; + } + + _pins--; + if (_pins == 0 && !_closed && !Array.Exists(_readers, static reader => reader.Started)) + { + CloseAndForgetLocked(); + } + } + } + + /// Removes this mailbox when no session pins it and every reader is idle. A racing raise then makes a new one. + internal bool TryEvict() + { + lock (_lock) + { + if (_closed) + { + return true; + } + + if (_pins > 0 || !Array.TrueForAll(_readers, static reader => reader.IsIdle)) + { + return false; + } + + CloseAndForgetLocked(); + return true; + } + } + + /// Takes no more notices. The readers finish what is queued, then end. + internal void Close() + { + lock (_lock) + { + _closed = true; + foreach (HookReader reader in _readers) + { + reader.Complete(); + } + } + } + + // After ConversationEnded, only the turns admitted before the end get through. Five facts are + // exempt: a refusal records no words and says AfterEnd itself; the unload after the end and + // a Fault about the conversation itself (a hook that failed on ConversationEnded, say) must still reach the + // other hooks; a LineSpoken is a record of the call, not a turn notice, so the caller's goodbye after the + // end still reaches the transcript; a CallEnded closes the call that the end left on the line. A Fault + // inside a turn follows its turn, so it never outruns that turn's notices. + private bool HeldBackByTheEnd(HookNotice notice) + { + HookScope scope = notice.Scope; + return _ended is { } ended + && scope.SessionId == ended.Session + && notice is not (TurnRefused or ConversationUnloaded or LineSpoken or CallEnded or Fault { Scope.TurnIndex: null }) + && !(scope.TurnIndex is int turn && turn <= ended.LastTurn); + } + + private void CloseAndForgetLocked() + { + _closed = true; + Hub.Forget(_conversationId, this); + foreach (HookReader reader in _readers) + { + reader.Complete(); + } + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs b/src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs new file mode 100644 index 00000000..b7c4584b --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs @@ -0,0 +1,45 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks.Engine +{ + /// Calls the method that matches a notice's type. + internal static class NoticeDispatch + { + internal static ValueTask DeliverAsync(AgentHook hook, HookNotice notice, CancellationToken cancellationToken) + { + return notice switch + { + HostStarted n => hook.OnHostStartedAsync(n, cancellationToken), + HostStopping n => hook.OnHostStoppingAsync(n, cancellationToken), + RetentionSwept n => hook.OnRetentionSweptAsync(n, cancellationToken), + CallStarted n => hook.OnCallStartedAsync(n, cancellationToken), + LineSpoken n => hook.OnLineSpokenAsync(n, cancellationToken), + CallEnded n => hook.OnCallEndedAsync(n, cancellationToken), + ConversationStarted n => hook.OnConversationStartedAsync(n, cancellationToken), + ConversationUnloaded n => hook.OnConversationUnloadedAsync(n, cancellationToken), + ConversationEnded n => hook.OnConversationEndedAsync(n, cancellationToken), + TurnStarted n => hook.OnTurnStartedAsync(n, cancellationToken), + TurnRefused n => hook.OnTurnRefusedAsync(n, cancellationToken), + TurnSuperseded n => hook.OnTurnSupersededAsync(n, cancellationToken), + InputModerated n => hook.OnInputModeratedAsync(n, cancellationToken), + KnowledgeSearched n => hook.OnKnowledgeSearchedAsync(n, cancellationToken), + SkillLoaded n => hook.OnSkillLoadedAsync(n, cancellationToken), + Compacted n => hook.OnCompactedAsync(n, cancellationToken), + ReplyUpdated n => hook.OnReplyUpdatedAsync(n, cancellationToken), + ModelCalled n => hook.OnModelCalledAsync(n, cancellationToken), + ToolCalled n => hook.OnToolCalledAsync(n, cancellationToken), + ApprovalChanged n => hook.OnApprovalChangedAsync(n, cancellationToken), + FilePublished n => hook.OnFilePublishedAsync(n, cancellationToken), + SubagentStarted n => hook.OnSubagentStartedAsync(n, cancellationToken), + SubagentEnded n => hook.OnSubagentEndedAsync(n, cancellationToken), + TurnCompleted n => hook.OnTurnCompletedAsync(n, cancellationToken), + ReplyCut n => hook.OnReplyCutAsync(n, cancellationToken), + VoiceStateChanged n => hook.OnVoiceStateChangedAsync(n, cancellationToken), + TurnLatency n => hook.OnTurnLatencyAsync(n, cancellationToken), + StageChanged n => hook.OnStageChangedAsync(n, cancellationToken), + Fault n => hook.OnFaultAsync(n, cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(notice), notice.GetType().Name, "The notice is outside the closed set."), + }; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs b/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs new file mode 100644 index 00000000..95c935b8 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs @@ -0,0 +1,193 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Hooks.Engine +{ + /// Raises notices: one mailbox per conversation id, one for the host. It never runs hook code. + /// Which hooks want which notices. + /// Where a failing or slow hook is reported. + /// The clock of the notice timeouts, the slow-hook log, and the idle eviction. + internal sealed class NoticeHub(HookTable table, ILogger logger, TimeProvider timers) + { + internal static readonly TimeSpan IdleAfter = TimeSpan.FromSeconds(60); + + internal static readonly TimeSpan StillWaitingEvery = TimeSpan.FromSeconds(30); + + private readonly ConcurrentDictionary _mailboxes = new(StringComparer.Ordinal); + + private Mailbox? _host; + + private int _stopped; + + internal HookTable Table => table; + + internal ILogger Logger => logger; + + internal TimeProvider Timers => timers; + + internal int MailboxCount => _mailboxes.Count; + + internal bool Wants(Type noticeType) => table.Wants(noticeType); + + /// Stamps one notice and queues it for every hook that wants it. + /// The fact, with its scope's conversation, session and turn already set. + /// The clock is read from: the session's. + /// For : the last turn admitted before the end, or -1. + /// A hook that must not get this notice: the one a Fault(HookFailed) is about. + /// + /// The moment the fact happened, when the raiser read it earlier than the raise (a turn's end), or + /// to stamp it from . + /// + /// The notice's . A notice nobody wants still gets a fresh one. + internal Guid Raise( + HookNotice notice, TimeProvider clock, int? endsAfterTurn = null, AgentHook? except = null, DateTimeOffset? occurredAt = null) + { + ArgumentNullException.ThrowIfNull(notice); + ArgumentNullException.ThrowIfNull(clock); + + if (!table.Wants(notice.GetType())) + { + // The ended rule matters only where readers exist, so an end nobody hears never makes a mailbox. + if (notice is ConversationEnded && notice.Scope.ConversationId is { } id && _mailboxes.TryGetValue(id, out Mailbox? box)) + { + box.MarkEnded(notice.Scope.SessionId, endsAfterTurn); + } + + return Guid.CreateVersion7(); + } + + while (Volatile.Read(ref _stopped) == 0) + { + if (MailboxFor(notice.Scope.ConversationId).TryRaise(notice, clock, endsAfterTurn, except, occurredAt, out Guid eventId)) + { + return eventId; + } + } + + return Guid.CreateVersion7(); + } + + /// Completes when every notice raised so far for this conversation was handled by every hook. + internal Task FlushAsync(string? conversationId) + { + Mailbox? box = conversationId is null ? Volatile.Read(ref _host) : _mailboxes.GetValueOrDefault(conversationId); + return box?.FlushAsync() ?? Task.CompletedTask; + } + + /// Completes when every notice raised so far, in every conversation and on the host, was handled. + internal Task FlushAllAsync() + { + List barriers = [.. _mailboxes.Values.Select(static box => box.FlushAsync())]; + if (Volatile.Read(ref _host) is { } host) + { + barriers.Add(host.FlushAsync()); + } + + return Task.WhenAll(barriers); + } + + /// Whether this process still holds the conversation's mailbox from another session. + internal bool LoadedBefore(string conversationId, Guid sessionId) + { + return _mailboxes.TryGetValue(conversationId, out Mailbox? box) && box.LoadedBefore(sessionId); + } + + /// + /// Keeps the conversation's mailbox, and so its Sequence, while a session of it is loaded. Every pin + /// is matched by one . + /// + internal void Pin(string conversationId) + { + ArgumentNullException.ThrowIfNull(conversationId); + + if (table.NoticeHooks.Count == 0) + { + return; + } + + while (Volatile.Read(ref _stopped) == 0 && !MailboxFor(conversationId).TryPin()) + { + // The mailbox was evicted between the lookup and the pin; the next lookup makes a new one. + } + } + + /// Lets go of one . The mailbox is evicted once it is idle and no pin is left. + internal void Release(string conversationId) + { + ArgumentNullException.ThrowIfNull(conversationId); + + if (_mailboxes.TryGetValue(conversationId, out Mailbox? box)) + { + box.Release(); + } + } + + /// Closes every mailbox and waits, up to , for the queued notices. + internal async ValueTask StopAsync(TimeSpan timeout) + { + if (Interlocked.Exchange(ref _stopped, 1) == 1) + { + return; + } + + List boxes = [.. _mailboxes.Values]; + if (Volatile.Read(ref _host) is { } host) + { + boxes.Add(host); + } + + foreach (Mailbox box in boxes) + { + box.Close(); + } + + Task drained = Task.WhenAll(boxes.SelectMany(static box => box.Readers)); + + // A timer refuses a wait past HookReader.LongestTimeout; one that long is no limit at all. + if (timeout == Timeout.InfiniteTimeSpan || timeout > HookReader.LongestTimeout) + { + await drained.ConfigureAwait(false); + return; + } + + try + { + await drained.WaitAsync(timeout < TimeSpan.Zero ? TimeSpan.Zero : timeout, timers).ConfigureAwait(false); + } + catch (TimeoutException) + { + int draining = boxes.Count(static box => box.Readers.Any(static reader => !reader.IsCompleted)); + HookLog.DrainTimedOut(logger, draining, (long)timeout.TotalMilliseconds); + } + } + + internal void Forget(string? conversationId, Mailbox box) + { + if (conversationId is null) + { + _ = Interlocked.CompareExchange(ref _host, null, box); + return; + } + + _ = _mailboxes.TryRemove(KeyValuePair.Create(conversationId, box)); + } + + // A mailbox GetOrAdd builds but a racing caller discards never gets a write, so it never starts a reader. + private Mailbox MailboxFor(string? conversationId) + { + if (conversationId is not null) + { + return _mailboxes.GetOrAdd(conversationId, static (id, hub) => new Mailbox(hub, id), this); + } + + if (Volatile.Read(ref _host) is { } host) + { + return host; + } + + Mailbox made = new(this, null); + return Interlocked.CompareExchange(ref _host, made, null) ?? made; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs b/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs new file mode 100644 index 00000000..0bb742fb --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks.Engine +{ + /// + /// The notices of one loaded session. Every raise site of the session and of its turn's layers goes through + /// this (the turn carries it as TurnInvocation.Hooks). It pins the conversation's mailbox while the + /// session is loaded, so an idle eviction never restarts the conversation's Sequence. + /// + internal sealed class SessionHooks + { + private readonly HookRuntime _runtime; + + private readonly string _conversationId; + + private readonly string _entry; + + private readonly TimeProvider _time; + + private int _ended; + + private int _started; + + private int _released; + + internal SessionHooks(HookRuntime runtime, string conversationId, string entry, TimeProvider time) + { + _runtime = runtime; + _conversationId = conversationId; + _entry = entry; + _time = time; + runtime.Notices.Pin(conversationId); + } + + internal HookRuntime Runtime => _runtime; + + internal string ConversationId => _conversationId; + + /// Gets this session instance's id: a version 7 Guid, so it sorts by load time. + internal Guid SessionId { get; } = Guid.CreateVersion7(); + + internal TimeProvider Time => _time; + + /// + /// Gets the BeforeToolApproval decisions still open, by function call id: a denial the deny layer has not answered yet, or a call + /// left to a human who has not answered yet. MAF asks the auto rule again about every queued request on every run, + /// so this is what keeps the hooks to one decision per call. + /// + internal ConcurrentDictionary ApprovalDecisions { get; } = new(StringComparer.Ordinal); + + internal HookScope Scope(int? turnIndex, string? stage) => + new(_conversationId, _entry, turnIndex, stage, SessionId, 0, _time.GetUtcNow()); + + internal bool Wants() + where TNotice : HookNotice => _runtime.Notices.Wants(typeof(TNotice)); + + /// Raises one notice of this session. + /// The fact. + /// When it happened, if it was read before the raise (a turn's end), or for now. + internal Guid Raise(HookNotice notice, DateTimeOffset? occurredAt = null) => _runtime.Notices.Raise(notice, _time, occurredAt: occurredAt); + + /// + /// Raises , once per session. The caller holds the session's turn lock, so the + /// start always takes a lower Sequence than the end or refusal that may make it. + /// + internal void RaiseStarted(ConversationOrigin origin) + { + if (Interlocked.Exchange(ref _started, 1) == 0) + { + _ = Raise(new ConversationStarted(Scope(turnIndex: null, stage: null), origin)); + } + } + + /// + /// Raises the start of a session that ends or refuses a turn before it opened its store, so no end or refusal is + /// ever the first fact of a session. The caller holds the session's turn lock. + /// + internal void EnsureStarted() + { + if (Volatile.Read(ref _started) == 0) + { + RaiseStarted(_runtime.Notices.LoadedBefore(_conversationId, SessionId) ? ConversationOrigin.Reloaded : ConversationOrigin.Unopened); + } + } + + /// Raises the end, once. From then on, only notices of turns up to get through. + /// when this session had already ended. + internal bool RaiseEnd(ConversationEnded notice, int lastAdmittedTurn) + { + if (Interlocked.Exchange(ref _ended, 1) == 1) + { + return false; + } + + _ = _runtime.Notices.Raise(notice, _time, endsAfterTurn: lastAdmittedTurn); + return true; + } + + internal void RaiseFault(Fault fault, AgentHook except) + { + _ = _runtime.Notices.Raise(fault, _time, except: except); + } + + internal ConversationOrigin OriginOf(bool storeHeldConversation) + { + if (_runtime.Notices.LoadedBefore(_conversationId, SessionId)) + { + return ConversationOrigin.Reloaded; + } + + return storeHeldConversation ? ConversationOrigin.Resumed : ConversationOrigin.New; + } + + internal Task FlushAsync() => _runtime.Notices.FlushAsync(_conversationId); + + /// Lets go of the mailbox pin, once. The session was disposed or unloaded. + internal void Release() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + _runtime.Notices.Release(_conversationId); + } + } + } +} diff --git a/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs b/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs new file mode 100644 index 00000000..4174dc4c --- /dev/null +++ b/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs @@ -0,0 +1,13 @@ +namespace AgentCore.Application.Hooks.Engine +{ + /// Numbers the model round trips of one turn, from 0. Nested runs share their parent's counter. + internal sealed class TurnRounds + { + private int _next = -1; + + internal int Next() => Interlocked.Increment(ref _next); + + /// Gets how many model round trips the turn has made so far. + internal int Count => Volatile.Read(ref _next) + 1; + } +} diff --git a/src/AgentCore.Application/Hooks/GatePoint.cs b/src/AgentCore.Application/Hooks/GatePoint.cs new file mode 100644 index 00000000..514e3d53 --- /dev/null +++ b/src/AgentCore.Application/Hooks/GatePoint.cs @@ -0,0 +1,81 @@ +namespace AgentCore.Application.Hooks +{ + /// + /// One of the 13 points where AgentCore awaits its hooks. The point, not the hook, fixes the deadline and the + /// default failure policy. + /// + public sealed class GatePoint + { + private GatePoint(string name, TimeSpan deadline, HookFailure defaultFailure) + { + Name = name; + Deadline = deadline; + DefaultFailure = defaultFailure; + MethodName = name + "Async"; + } + + /// Gets the gate where a transport offers a call. Fails closed: the call is rejected as unavailable. + public static GatePoint BeforeCall { get; } = new(nameof(BeforeCall), TimeSpan.FromSeconds(5), HookFailure.Closed); + + /// Gets the gate where a request needs an entry. Fails closed: the request is refused. + public static GatePoint BeforeEntry { get; } = new(nameof(BeforeEntry), TimeSpan.FromSeconds(2), HookFailure.Closed); + + /// Gets the gate where the user's message arrives, before moderation and the model. + public static GatePoint BeforeTurn { get; } = new(nameof(BeforeTurn), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets the gate once per run, when its context is built. + public static GatePoint BeforeRun { get; } = new(nameof(BeforeRun), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets the gate where AgentCore is about to compact. + public static GatePoint BeforeCompaction { get; } = new(nameof(BeforeCompaction), TimeSpan.FromSeconds(5), HookFailure.Open); + + /// Gets the gate before each model round trip. + public static GatePoint BeforeModel { get; } = new(nameof(BeforeModel), TimeSpan.FromSeconds(1), HookFailure.Open); + + /// Gets the gate after each round trip, before tools run or text leaves. + public static GatePoint AfterModel { get; } = new(nameof(AfterModel), TimeSpan.FromSeconds(1), HookFailure.Open); + + /// Gets the gate where a round trip threw. + public static GatePoint AfterModelFailed { get; } = new(nameof(AfterModelFailed), TimeSpan.FromSeconds(1), HookFailure.Open); + + /// Gets the gate where a tool that needs approval was called. + public static GatePoint BeforeToolApproval { get; } = new(nameof(BeforeToolApproval), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets the gate before each tool call. + public static GatePoint BeforeTool { get; } = new(nameof(BeforeTool), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets the gate after a tool returned. + public static GatePoint AfterTool { get; } = new(nameof(AfterTool), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets the gate where a tool threw. + public static GatePoint AfterToolFailed { get; } = new(nameof(AfterToolFailed), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets the gate where the run has a reply, before the turn is sealed. + public static GatePoint AfterRun { get; } = new(nameof(AfterRun), TimeSpan.FromSeconds(2), HookFailure.Open); + + /// Gets every gate point, in gate order. + public static IReadOnlyList All { get; } = + [ + BeforeCall, BeforeEntry, BeforeTurn, BeforeRun, BeforeCompaction, BeforeModel, AfterModel, + AfterModelFailed, BeforeToolApproval, BeforeTool, AfterTool, AfterToolFailed, AfterRun, + ]; + + /// Gets the point's name, which is its method without Async. + public string Name { get; } + + /// Gets how long one hook may take before it is abandoned. + public TimeSpan Deadline { get; } + + /// Gets what a failing hook does unless it overrides . + public HookFailure DefaultFailure { get; } + + /// Gets the method this point calls. + internal string MethodName { get; } + + /// + public override string ToString() + { + return Name; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs b/src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs new file mode 100644 index 00000000..2acbfab6 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs @@ -0,0 +1,59 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// A tool that needs approval was called. A hook may approve or deny it; with no decision a human is asked. + public sealed class ApprovalGate : HookGate + { + internal ApprovalGate( + HookScope scope, + string toolName, + string callId, + IReadOnlyDictionary arguments, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(toolName); + ArgumentNullException.ThrowIfNull(callId); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(items); + + ToolName = toolName; + CallId = callId; + Arguments = arguments; + Items = items; + } + + /// Gets the name the model called. + public string ToolName { get; } + + /// Gets the id the model gave this call. + public string CallId { get; } + + /// Gets the arguments the model sent. + public IReadOnlyDictionary Arguments { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal bool? Approved { get; private set; } + + internal string? DenyReason { get; private set; } + + /// Lets the tool run without asking a human. Terminal. + public void Approve() + { + Stage(terminal: true, () => Approved = true); + } + + /// Refuses the call without asking a human. The model is told . Terminal. + /// Why the call was refused. + public void Deny(string reason) + { + ArgumentNullException.ThrowIfNull(reason); + Stage(terminal: true, () => + { + Approved = false; + DenyReason = reason; + }); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/CallGate.cs b/src/AgentCore.Application/Hooks/Gates/CallGate.cs new file mode 100644 index 00000000..682d91dc --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/CallGate.cs @@ -0,0 +1,77 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// A transport offers a call. A hook accepts it onto a conversation or rejects it. + public sealed class CallGate : HookGate + { + internal const int MaxBriefLength = 1000; + + internal CallGate( + HookScope scope, + string entry, + string callId, + string? from, + string? to, + IReadOnlyDictionary headers, + string transport) + : base(scope) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(callId); + ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(transport); + + Entry = entry; + CallId = callId; + From = from; + To = to; + Headers = headers; + Transport = transport; + } + + /// Gets the entry the call's route serves. + public string Entry { get; } + + /// Gets the transport's id for this call. + public string CallId { get; } + + /// Gets the caller's number or address, when the transport knows it. + public string? From { get; } + + /// Gets the number or address that was called, when the transport knows it. + public string? To { get; } + + /// Gets the transport's headers, for example SIP headers. + public IReadOnlyDictionary Headers { get; } + + /// Gets the kind of transport that offered the call. + public string Transport { get; } + + internal string? AcceptedConversationId { get; private set; } + + internal string? Brief { get; private set; } + + internal CallRefusal? Refusal { get; private set; } + + /// Takes the call onto a conversation. Terminal. + /// The conversation the call joins. AgentCore opens it or resumes it. + /// Text the agent is told about this call. Over 1,000 characters it is cut to its first 1,000. + public void Accept(string conversationId, string? brief = null) + { + ArgumentException.ThrowIfNullOrEmpty(conversationId); + + string? kept = brief is { Length: > MaxBriefLength } ? brief[..MaxBriefLength] : brief; + Stage(terminal: true, () => + { + AcceptedConversationId = conversationId; + Brief = kept; + }); + } + + /// Turns the call away. Each transport maps to its own code. Terminal. + /// Why the call is refused. + public void Reject(CallRefusal reason) + { + Stage(terminal: true, () => Refusal = reason); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/CallRefusal.cs b/src/AgentCore.Application/Hooks/Gates/CallRefusal.cs new file mode 100644 index 00000000..f2c3aa07 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/CallRefusal.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// Why a call was turned away. Each transport maps these to its own code. + public enum CallRefusal + { + /// A hook declined the call. What declining means is up to the hook and the host. + Declined, + + /// A hook turned the call away as busy. What busy means is up to the hook and the host. + Busy, + + /// + /// No hook accepted the call: none answered, a hook failed, or a hook missed the gate's deadline. A hook may + /// also give it. + /// + Unavailable, + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/CompactionGate.cs b/src/AgentCore.Application/Hooks/Gates/CompactionGate.cs new file mode 100644 index 00000000..b205793c --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/CompactionGate.cs @@ -0,0 +1,39 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// AgentCore is about to compact. A hook may cancel the compaction or supply its summary. + public sealed class CompactionGate : HookGate + { + internal CompactionGate(HookScope scope, int messageCount, IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(items); + + MessageCount = messageCount; + Items = items; + } + + /// Gets how many messages the compaction may fold: the stored messages before the newest turn. + public int MessageCount { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal bool Cancelled { get; private set; } + + internal string? Summary { get; private set; } + + /// Skips this compaction: the conversation keeps the view it had. Terminal. + public void Cancel() + { + Stage(terminal: true, () => Cancelled = true); + } + + /// Uses as the summary, and the summarising model is not called. Terminal. + /// The summary. + public void ReplaceSummary(string text) + { + ArgumentNullException.ThrowIfNull(text); + Stage(terminal: true, () => Summary = text); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/EntryGate.cs b/src/AgentCore.Application/Hooks/Gates/EntryGate.cs new file mode 100644 index 00000000..21f72dbe --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/EntryGate.cs @@ -0,0 +1,63 @@ +using System.Security.Claims; + +namespace AgentCore.Application.Hooks.Gates +{ + /// A request needs an entry. A hook chooses one or refuses the request. + public sealed class EntryGate : HookGate + { + internal EntryGate( + HookScope scope, + ClaimsPrincipal? user, + IReadOnlyDictionary headers, + string route, + string? requestedEntry, + EntryTransport transport) + : base(scope) + { + ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(route); + + User = user; + Headers = headers; + Route = route; + RequestedEntry = requestedEntry; + Transport = transport; + } + + /// Gets the authenticated user, or when the request is anonymous. + public ClaimsPrincipal? User { get; } + + /// + /// Gets the HTTP request's headers, on every route. On a call route that is the handshake or webhook request; + /// the call's own SIP headers reach . + /// + public IReadOnlyDictionary Headers { get; } + + /// Gets the route pattern that matched. + public string Route { get; } + + /// Gets the entry the URL named, or when the route has no entry segment. + public string? RequestedEntry { get; } + + /// Gets how the request arrived. + public EntryTransport Transport { get; } + + internal string? Chosen { get; private set; } + + internal bool Refused { get; private set; } + + /// Serves the request with this entry. Terminal. + /// The name of an entry the host serves. + public void Choose(string entry) + { + ArgumentException.ThrowIfNullOrEmpty(entry); + Stage(terminal: true, () => Chosen = entry); + } + + /// Refuses the request. Terminal. + public void Refuse() + { + Stage(terminal: true, () => Refused = true); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs b/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs new file mode 100644 index 00000000..1d91fa36 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// How a request reached an AgentCore route. + public enum EntryTransport + { + /// An HTTP request. + Http, + + /// A phone call. + Call, + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs b/src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs new file mode 100644 index 00000000..433adfce --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Gates +{ + /// A model round trip threw. A hook may retry it or answer for it. + public sealed class ModelFailureGate : HookGate + { + internal ModelFailureGate( + HookScope scope, + int round, + int attempt, + Exception exception, + bool canRetry, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(items); + + Round = round; + Attempt = attempt; + Exception = exception; + CanRetry = canRetry; + Items = items; + } + + /// Gets the zero-based round trip of this run. + public int Round { get; } + + /// Gets how many retries this round already had. + public int Attempt { get; } + + /// Gets what the round trip threw. + public Exception Exception { get; } + + /// + /// Gets whether is allowed: false once the caller received part of this round, and on the + /// last attempt the retry cap allows. + /// + public bool CanRetry { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal bool Retrying { get; private set; } + + internal ChatResponse? Response { get; private set; } + + /// Calls the model again. Allowed only before the first stream update reached the caller. Terminal. + /// is false. + public void Retry() + { + if (!CanRetry) + { + throw new InvalidOperationException("This round cannot be retried: the caller already received part of it, or it reached the retry cap."); + } + + Stage(terminal: true, () => Retrying = true); + } + + /// + /// Answers the round with this response instead of today's fallback. Terminal. After part of the round reached + /// the caller, this answer follows it as its own message, as the fallback does. + /// + /// The response the round trip returns. + public void Respond(ChatResponse response) + { + ArgumentNullException.ThrowIfNull(response); + Stage(terminal: true, () => Response = response); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ModelGate.cs b/src/AgentCore.Application/Hooks/Gates/ModelGate.cs new file mode 100644 index 00000000..60500c75 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ModelGate.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Gates +{ + /// A model round trip is about to start. A hook may change what the model sees, or answer for it. + public sealed class ModelGate : HookGate + { + internal ModelGate( + HookScope scope, + int round, + IReadOnlyList messages, + ChatOptions? options, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(messages); + ArgumentNullException.ThrowIfNull(items); + + Round = round; + Messages = messages; + Options = options; + Items = items; + } + + /// Gets the zero-based round trip of this run. + public int Round { get; } + + /// Gets the messages the model will see, as the hook before this one left them. + public IReadOnlyList Messages { get; } + + /// Gets the options of the call, as the hook before this one left them. + public ChatOptions? Options { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal IReadOnlyList? NewMessages { get; private set; } + + internal ChatOptions? NewOptions { get; private set; } + + internal ChatResponse? Response { get; private set; } + + /// Sends the model these messages instead. + /// The messages the model sees. + public void ReplaceMessages(IEnumerable messages) + { + ArgumentNullException.ThrowIfNull(messages); + List copy = [.. messages]; + Stage(terminal: false, () => NewMessages = copy); + } + + /// Calls the model with these options instead. + /// The options of the call. + public void SetOptions(ChatOptions options) + { + ArgumentNullException.ThrowIfNull(options); + Stage(terminal: false, () => NewOptions = options); + } + + /// Does not call the model. This response stands in for its answer. Terminal. + /// The response the round trip returns. + public void Respond(ChatResponse response) + { + ArgumentNullException.ThrowIfNull(response); + Stage(terminal: true, () => Response = response); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs b/src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs new file mode 100644 index 00000000..0e58f909 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Gates +{ + /// A model round trip returned, before tools run or text leaves. A hook may replace the response. + public sealed class ModelResultGate : HookGate + { + internal ModelResultGate(HookScope scope, int round, ChatResponse response, IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(response); + ArgumentNullException.ThrowIfNull(items); + + Round = round; + Response = response; + Items = items; + } + + /// Gets the zero-based round trip of this run. + public int Round { get; } + + /// Gets the model's response, as the hook before this one left it. + public ChatResponse Response { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal ChatResponse? Replacement { get; private set; } + + /// Uses this response in place of the model's. Replaced arguments or an injected tool call reach the tool loop. + /// The response the round trip returns. + public void ReplaceResponse(ChatResponse response) + { + ArgumentNullException.ThrowIfNull(response); + Stage(terminal: false, () => Replacement = response); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/RunEndGate.cs b/src/AgentCore.Application/Hooks/Gates/RunEndGate.cs new file mode 100644 index 00000000..3de6bb14 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/RunEndGate.cs @@ -0,0 +1,54 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// + /// A run has a reply, before the turn is sealed. A hook may ask the agent to run again. It does not fire for a + /// graph entry, for a nested run, for the run that reaches the loop cap, for a run that ends on a pending approval + /// request, or for a run where an earlier loop: until: evaluator already asked to continue. + /// + public sealed class RunEndGate : HookGate + { + internal RunEndGate( + HookScope scope, + string? agentId, + string replyText, + int iteration, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(replyText); + ArgumentNullException.ThrowIfNull(items); + + AgentId = agentId; + ReplyText = replyText; + Iteration = iteration; + Items = items; + } + + /// Gets the agent that ran, when known. + public string? AgentId { get; } + + /// Gets the run's reply text. + public string ReplyText { get; } + + /// Gets how many runs of this turn have completed: 1 after the first run. + public int Iteration { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal string? Continuation { get; private set; } + + /// + /// Runs the agent again with . The next run reads the conversation, this turn's user + /// message and earlier runs, then the message. The message is not a user row, and the caller never sees it. Runs + /// are capped by the agent's loop: maxRounds:; a loop: block without maxRounds: + /// caps at the framework's default of 10, and an agent with no loop: at 3. Terminal. + /// + /// What the agent is told for the next run. + public void Continue(string message) + { + ArgumentNullException.ThrowIfNull(message); + Stage(terminal: true, () => Continuation = message); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/RunGate.cs b/src/AgentCore.Application/Hooks/Gates/RunGate.cs new file mode 100644 index 00000000..286d033a --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/RunGate.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Gates +{ + /// A run's context is being built. A hook may add instructions, messages, or tools. + public sealed class RunGate : HookGate + { + private readonly List _instructions = []; + + private readonly List _messages = []; + + private readonly List _tools = []; + + internal RunGate(HookScope scope, string agentId, bool nested, IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(agentId); + ArgumentNullException.ThrowIfNull(items); + + AgentId = agentId; + Nested = nested; + Items = items; + } + + /// Gets the agent about to run. + public string AgentId { get; } + + /// Gets whether this run belongs to a graph participant or an agent-as-tool child. + public bool Nested { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal IReadOnlyList Instructions => _instructions; + + internal IReadOnlyList Messages => _messages; + + internal IReadOnlyList Tools => _tools; + + /// Adds text to this run's instructions. + /// The instructions to add. + public void AddInstructions(string text) + { + ArgumentNullException.ThrowIfNull(text); + Stage(terminal: false, () => _instructions.Add(text)); + } + + /// Adds messages to this run's context. + /// The messages to add. + public void AddMessages(IEnumerable messages) + { + ArgumentNullException.ThrowIfNull(messages); + List copy = [.. messages]; + Stage(terminal: false, () => _messages.AddRange(copy)); + } + + /// Adds tools to this run. + /// The tools to add. + public void AddTools(IEnumerable tools) + { + ArgumentNullException.ThrowIfNull(tools); + List copy = [.. tools]; + Stage(terminal: false, () => _tools.AddRange(copy)); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs b/src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs new file mode 100644 index 00000000..0f198354 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs @@ -0,0 +1,76 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// A tool threw. A hook may retry the call or answer for it; with no decision AgentCore's own policy applies. + public sealed class ToolFailureGate : HookGate + { + internal ToolFailureGate( + HookScope scope, + string toolName, + string callId, + int attempt, + Exception exception, + bool canRetry, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(toolName); + ArgumentNullException.ThrowIfNull(callId); + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(items); + + ToolName = toolName; + CallId = callId; + Attempt = attempt; + Exception = exception; + CanRetry = canRetry; + Items = items; + } + + /// Gets the name the model called. + public string ToolName { get; } + + /// Gets the id the model gave this call. + public string CallId { get; } + + /// Gets how many retries this call already had. + public int Attempt { get; } + + /// Gets what the tool threw. + public Exception Exception { get; } + + /// Gets whether is allowed: false on the last attempt the retry cap allows. + public bool CanRetry { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal bool Retrying { get; private set; } + + internal bool Responded { get; private set; } + + internal object? Result { get; private set; } + + /// Calls the tool again. The model sees the second result. Terminal. + /// is false. + public void Retry() + { + if (!CanRetry) + { + throw new InvalidOperationException("This call reached the retry cap, so it cannot be retried."); + } + + Stage(terminal: true, () => Retrying = true); + } + + /// Answers for the tool with instead of AgentCore's own failure policy. Terminal. + /// The result the model reads. + public void Respond(object? result) + { + Stage(terminal: true, () => + { + Responded = true; + Result = result; + }); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ToolGate.cs b/src/AgentCore.Application/Hooks/Gates/ToolGate.cs new file mode 100644 index 00000000..fbc45a1f --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ToolGate.cs @@ -0,0 +1,83 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// A tool is about to run. A hook may block it, answer for it, change its arguments, or end the loop after it. + public sealed class ToolGate : HookGate + { + internal ToolGate( + HookScope scope, + string toolName, + string callId, + IReadOnlyDictionary arguments, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(toolName); + ArgumentNullException.ThrowIfNull(callId); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(items); + + ToolName = toolName; + CallId = callId; + Arguments = arguments; + Items = items; + } + + /// Gets the name the model called. + public string ToolName { get; } + + /// Gets the id the model gave this call. + public string CallId { get; } + + /// Gets the arguments, as the model sent them or as the hook before this one replaced them. + public IReadOnlyDictionary Arguments { get; } + + /// Gets the turn's shared state. Tools read the same dictionary through ToolCallScope.Items. + public IDictionary Items { get; } + + internal bool Blocked { get; private set; } + + internal bool Responded { get; private set; } + + internal object? Result { get; private set; } + + internal IReadOnlyDictionary? NewArguments { get; private set; } + + internal bool EndsLoop { get; private set; } + + /// Does not run the tool. The model reads as the tool's answer. Terminal. + /// What the model is told, for example why the call was refused. + public void Block(object? resultForModel) + { + Stage(terminal: true, () => + { + Blocked = true; + Result = resultForModel; + }); + } + + /// Does not run the tool, and answers for it with . Terminal. + /// The tool's answer. + public void Respond(object? result) + { + Stage(terminal: true, () => + { + Responded = true; + Result = result; + }); + } + + /// Runs the tool with these arguments. The model's own call record keeps what the model sent. + /// The arguments the tool gets. A key left out is removed. + public void ReplaceArguments(IReadOnlyDictionary arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + Stage(terminal: false, () => NewArguments = arguments); + } + + /// Ends the tool loop after this call: the model is not asked again this run. + public void EndLoop() + { + Stage(terminal: false, () => EndsLoop = true); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs b/src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs new file mode 100644 index 00000000..68949724 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs @@ -0,0 +1,59 @@ +namespace AgentCore.Application.Hooks.Gates +{ + /// A tool returned. A hook may replace its result or end the loop after it. + public sealed class ToolResultGate : HookGate + { + internal ToolResultGate( + HookScope scope, + string toolName, + string callId, + object? result, + IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(toolName); + ArgumentNullException.ThrowIfNull(callId); + ArgumentNullException.ThrowIfNull(items); + + ToolName = toolName; + CallId = callId; + Result = result; + Items = items; + } + + /// Gets the name the model called. + public string ToolName { get; } + + /// Gets the id the model gave this call. + public string CallId { get; } + + /// Gets the tool's result, as the hook before this one left it. + public object? Result { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal bool Replaced { get; private set; } + + internal object? NewResult { get; private set; } + + internal bool EndsLoop { get; private set; } + + /// Tells the model instead of what the tool returned. + /// The result the model reads. + public void ReplaceResult(object? result) + { + Stage(terminal: false, () => + { + Replaced = true; + NewResult = result; + }); + } + + /// Ends the tool loop after this call: the model is not asked again this run. + public void EndLoop() + { + Stage(terminal: false, () => EndsLoop = true); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Gates/TurnGate.cs b/src/AgentCore.Application/Hooks/Gates/TurnGate.cs new file mode 100644 index 00000000..2551267d --- /dev/null +++ b/src/AgentCore.Application/Hooks/Gates/TurnGate.cs @@ -0,0 +1,62 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Hooks.Gates +{ + /// The user's message arrived, before moderation and the model. A hook may block it, rewrite it, or add context. + public sealed class TurnGate : HookGate + { + private readonly List _addedContext = []; + + internal TurnGate(HookScope scope, string userText, IDictionary items) + : base(scope) + { + ArgumentNullException.ThrowIfNull(userText); + ArgumentNullException.ThrowIfNull(items); + + UserText = userText; + Items = items; + } + + /// Gets the user's text, after any earlier hook's . + public string UserText { get; } + + /// Gets the turn's shared state. + public IDictionary Items { get; } + + internal string? BlockReply { get; private set; } + + internal string? Input { get; private set; } + + internal IReadOnlyList AddedContext => _addedContext; + + /// Does not run the turn. It is committed with Outcome = Blocked and as the answer. Terminal. + /// The text the caller gets. + public void Block(string reply) + { + ArgumentNullException.ThrowIfNull(reply); + Stage(terminal: true, () => BlockReply = reply); + } + + /// + /// Rewrites the user's message. The store, TurnCompleted.UserText and the audit hash all see the new text. + /// The rewrite is made in place on the turn's user message, the same + /// object the host passed in. Its text becomes ; its other contents (an image, an approval + /// answer) are kept, but may be moved after the text. was raised before this gate, so it + /// keeps the words as received. + /// + /// The message the turn runs on. + public void ReplaceInput(string text) + { + ArgumentNullException.ThrowIfNull(text); + Stage(terminal: false, () => Input = text); + } + + /// Tells the model something for this turn's runs only. It is never stored and is not a user row. + /// The context note. + public void AddContext(string text) + { + ArgumentNullException.ThrowIfNull(text); + Stage(terminal: false, () => _addedContext.Add(text)); + } + } +} diff --git a/src/AgentCore.Application/Hooks/HookFailure.cs b/src/AgentCore.Application/Hooks/HookFailure.cs new file mode 100644 index 00000000..f86c4ed7 --- /dev/null +++ b/src/AgentCore.Application/Hooks/HookFailure.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks +{ + /// What a gate does when a hook throws or misses its deadline. + public enum HookFailure + { + /// Act as if the hook did nothing: its staged verbs are dropped and the chain goes on. + Open, + + /// Apply the gate's safe verb and stop the chain. + Closed, + } +} diff --git a/src/AgentCore.Application/Hooks/HookGate.cs b/src/AgentCore.Application/Hooks/HookGate.cs new file mode 100644 index 00000000..aad2db91 --- /dev/null +++ b/src/AgentCore.Application/Hooks/HookGate.cs @@ -0,0 +1,71 @@ +namespace AgentCore.Application.Hooks +{ + /// + /// One hook's view of a gate. It shows the facts as the hooks before this one left them, and takes this hook's + /// verbs. A terminal verb ends the chain; a modifying verb changes what the next hook sees. + /// + public abstract class HookGate + { + private readonly Lock _verbs = new(); + + private bool _sealed; + + private bool _terminal; + + private protected HookGate(HookScope scope) + { + ArgumentNullException.ThrowIfNull(scope); + Scope = scope; + } + + /// Gets where and when the gate fires. + public HookScope Scope { get; } + + /// Gets whether this view took a terminal verb. + internal bool IsTerminal + { + get + { + lock (_verbs) + { + return _terminal; + } + } + } + + /// Closes this view. Every verb after this throws. + internal void Seal() + { + lock (_verbs) + { + _sealed = true; + } + } + + /// Records one verb on this view. + /// Whether the verb ends the chain. + /// Writes the verb's value into the view's staged fields. + /// The view is sealed, or it already took a terminal verb. + private protected void Stage(bool terminal, Action apply) + { + ArgumentNullException.ThrowIfNull(apply); + + lock (_verbs) + { + if (_sealed) + { + throw new InvalidOperationException( + "The gate already moved past this hook, so this verb changes nothing. A hook is abandoned at its gate's deadline."); + } + + if (_terminal) + { + throw new InvalidOperationException("This gate already took a terminal verb, so no verb can follow it."); + } + + apply(); + _terminal = terminal; + } + } + } +} diff --git a/src/AgentCore.Application/Hooks/HookNotice.cs b/src/AgentCore.Application/Hooks/HookNotice.cs new file mode 100644 index 00000000..36f7cebc --- /dev/null +++ b/src/AgentCore.Application/Hooks/HookNotice.cs @@ -0,0 +1,10 @@ +namespace AgentCore.Application.Hooks +{ + /// One fact a hook is told about after it happened. AgentCore never waits for a notice. + /// Where and when it happened. + public abstract record HookNotice(HookScope Scope) + { + /// Gets the identity of this notice: a version 7 Guid. The audit hook uses it as the row id. + public Guid EventId { get; init; } + } +} diff --git a/src/AgentCore.Application/Hooks/HookScope.cs b/src/AgentCore.Application/Hooks/HookScope.cs new file mode 100644 index 00000000..25b82405 --- /dev/null +++ b/src/AgentCore.Application/Hooks/HookScope.cs @@ -0,0 +1,28 @@ +namespace AgentCore.Application.Hooks +{ + /// Where and when one gate or notice happens. Every gate object and every notice carries one. + /// + /// The conversation, or for a host notice and for the call and entry gates, which run + /// before a conversation exists. + /// + /// The entry the conversation runs, or before one is chosen. + /// The zero-based turn, or for a fact about the conversation itself. + /// The stage the machine held, empty when the entry declares no policy, or . + /// + /// The loaded session instance. A version 7 Guid, so it sorts by load time. on a host + /// notice and on a gate that runs before a session exists. + /// + /// + /// The notice's place among the notices of its conversation while the conversation stays loaded, from 1. + /// Ordering across an unload uses first. Gates carry 0. + /// + /// When it happened, on the session's clock. + public sealed record HookScope( + string? ConversationId, + string? Entry, + int? TurnIndex, + string? Stage, + Guid SessionId, + long Sequence, + DateTimeOffset OccurredAt); +} diff --git a/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs b/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs new file mode 100644 index 00000000..74fb28b0 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs @@ -0,0 +1,280 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Hooks.Layers +{ + /// The combined auto-approval rule, and the deny layer outside the approval layer. + internal static class ApprovalGateLayer + { + /// + /// The most model rounds of one run whose denials the deny layer answers. A run that only hands back a request MAF + /// already queued calls no model and does not count. The next model round that still asks for a denied call ends + /// the run as a fault. + /// + internal const int MaxRounds = 8; + + /// What the model is told when a hook that fails closed broke at BeforeToolApproval. + private const string FailedClosedReason = "a hook denied this call."; + + /// + /// The one rule UseToolApproval gets: the hook chain first, then the document's patterns. + /// approves; asks, or leaves a denial for the deny layer. + /// + internal static Func> Rule(IReadOnlyList auto, HookRuntime hooks) + { + return async context => + { + FunctionCallContent call = context.FunctionCallContent; + TurnInvocation? turn = TurnInvocation.From(context.RunOptions); + SessionHooks? session = turn?.Hooks; + + if (session is not null && hooks.Gates.Overrides(GatePoint.BeforeToolApproval)) + { + ApprovalDecision decision = session.ApprovalDecisions.TryGetValue(call.CallId, out ApprovalDecision? known) + ? known + : await DecideAsync(hooks, session, turn!, call).ConfigureAwait(false); + + if (decision.Approved is { } approved) + { + return approved; + } + } + + if (auto.Any(pattern => AgentApproval.Matches(pattern, call.Name))) + { + if (session is not null) + { + _ = session.ApprovalDecisions.TryRemove(call.CallId, out _); + Changed(session, session.Scope(turn!.TurnIndex, turn.Stage), call, ApprovalState.Approved, ApprovalBy.Rule, reason: null); + } + + return true; + } + + return false; + }; + } + + /// Adds the deny layer. Registered before UseToolApproval, so it is the outer one. + internal static AIAgentBuilder UseDenials(AIAgentBuilder builder) + { + return builder.Use( + runFunc: static (messages, session, options, inner, cancellationToken) => RunAsync(messages, session, options, inner, cancellationToken), + runStreamingFunc: static (messages, session, options, inner, cancellationToken) => StreamAsync(messages, session, options, inner, cancellationToken)); + } + + // Runs the chain once per call. An approval is settled the moment the rule returns true, so only a denial and a + // call left undecided are kept, for MAF's next question about the same call. + private static async ValueTask DecideAsync(HookRuntime hooks, SessionHooks session, TurnInvocation turn, FunctionCallContent call) + { + HookScope scope = session.Scope(turn.TurnIndex, turn.Stage); + IReadOnlyDictionary arguments = call.Arguments is { } given + ? new Dictionary(given, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal); + + // The rule has no CancellationToken; the gate's own deadline still bounds the wait. + ApprovalDecision decision = await hooks.Gates.RunAsync( + GatePoint.BeforeToolApproval, + scope, + new ApprovalDecision(Approved: null, Reason: null), + _ => new ApprovalGate(scope, call.Name, call.CallId, arguments, items), + static (hook, gate, token) => hook.BeforeToolApprovalAsync(gate, token), + static (state, gate) => gate.Approved is null ? state : new ApprovalDecision(gate.Approved, gate.DenyReason), + static _ => new ApprovalDecision(Approved: false, Reason: FailedClosedReason), + session.RaiseFault, + CancellationToken.None).ConfigureAwait(false); + + if (decision.Approved is false) + { + decision = decision with { Reason = decision.Reason ?? FailedClosedReason }; + Changed(session, scope, call, ApprovalState.Denied, ApprovalBy.Hook, decision.Reason); + } + else if (decision.Approved is true) + { + Changed(session, scope, call, ApprovalState.Approved, ApprovalBy.Hook, reason: null); + return decision; + } + + session.ApprovalDecisions[call.CallId] = decision; + return decision; + } + + private static void Changed(SessionHooks hooks, HookScope scope, FunctionCallContent call, ApprovalState state, ApprovalBy by, string? reason) + { + if (hooks.Wants()) + { + _ = hooks.Raise(new ApprovalChanged(scope, call.Name, call.CallId, state, by, reason)); + } + } + + /// The requests among a BeforeToolApproval hook denied and the layer has not answered yet, each with its reason. + private static List<(ToolApprovalRequestContent Request, string Reason)> DeniedOf(IEnumerable contents, AgentRunOptions? options) + { + if (TurnInvocation.From(options)?.Hooks is not { } session) + { + return []; + } + + List<(ToolApprovalRequestContent Request, string Reason)> denied = []; + foreach (ToolApprovalRequestContent request in contents.OfType()) + { + if (request.ToolCall is FunctionCallContent call + && session.ApprovalDecisions.TryGetValue(call.CallId, out ApprovalDecision? decision) + && decision is { Approved: false, Reason: { } reason }) + { + denied.Add((request, reason)); + } + } + + return denied; + } + + /// The contents with the denied requests taken out. + private static List Without(IList contents, List<(ToolApprovalRequestContent Request, string Reason)> denied) + { + return [.. contents.Where(content => + content is not ToolApprovalRequestContent request || !denied.Exists(pair => ReferenceEquals(pair.Request, request)))]; + } + + /// Answers the denials, settling them: the session forgets them and the turn knows their calls were refused. + private static ChatMessage Answer(List<(ToolApprovalRequestContent Request, string Reason)> denied, AgentRunOptions? options) + { + TurnInvocation? turn = TurnInvocation.From(options); + foreach ((ToolApprovalRequestContent request, _) in denied) + { + string callId = ((FunctionCallContent)request.ToolCall).CallId; + _ = turn?.Hooks?.ApprovalDecisions.TryRemove(callId, out _); + turn?.Results?.NoteDenied(callId); + } + + return new(ChatRole.User, [.. denied.Select(static pair => (AIContent)pair.Request.CreateResponse(false, pair.Reason))]); + } + + private static int? RoundsSoFar(AgentRunOptions? options) => TurnInvocation.From(options)?.Rounds?.Count; + + // Without a round counter every run counts, which can only end the run sooner. + private static bool CalledTheModel(int? before, AgentRunOptions? options) => before is null || RoundsSoFar(options) != before; + + /// The cap is reached with a denial still open: never a person's to answer. Logs, raises the fault, and throws. + private static InvalidOperationException Exhausted(List<(ToolApprovalRequestContent Request, string Reason)> denied, AgentRunOptions? options) + { + string message = $"A hook denied tool calls after {MaxRounds} model rounds of denials in one turn, so the turn ends without asking a person."; + if (TurnInvocation.From(options) is { Hooks: { } session } turn) + { + foreach ((ToolApprovalRequestContent request, _) in denied) + { + _ = session.ApprovalDecisions.TryRemove(((FunctionCallContent)request.ToolCall).CallId, out _); + } + + HookLog.DenialRoundsExhausted(session.Runtime.Logger, session.ConversationId, turn.TurnIndex, MaxRounds); + _ = session.Raise(new Fault(session.Scope(turn.TurnIndex, turn.Stage), FaultKind.DenialRoundsExhausted, message, Cause: null)); + } + + return new InvalidOperationException(message); + } + + private static async Task RunAsync( + IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent inner, CancellationToken cancellationToken) + { + session ??= await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + IEnumerable input = messages; + List all = []; + int answered = 0; + + while (true) + { + int? before = RoundsSoFar(options); + AgentResponse response = await inner.RunAsync(input, session, options, cancellationToken).ConfigureAwait(false); + List<(ToolApprovalRequestContent Request, string Reason)> denied = DeniedOf(response.Messages.SelectMany(static message => message.Contents), options); + + if (denied.Count == 0) + { + all.AddRange(response.Messages); + response.Messages = all; + return response; + } + + if (CalledTheModel(before, options) && answered++ == MaxRounds) + { + throw Exhausted(denied, options); + } + + foreach (ChatMessage message in response.Messages) + { + List kept = Without(message.Contents, denied); + if (kept.Count > 0) + { + all.Add(new ChatMessage(message.Role, kept) { MessageId = message.MessageId, AuthorName = message.AuthorName }); + } + } + + input = [Answer(denied, options)]; + } + } + + // Only updates that carry an approval request are held back; everything else streams through at once. + private static async IAsyncEnumerable StreamAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent inner, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + session ??= await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + IEnumerable input = messages; + int answered = 0; + + while (true) + { + int? before = RoundsSoFar(options); + List held = []; + await foreach (AgentResponseUpdate update in inner.RunStreamingAsync(input, session, options, cancellationToken).ConfigureAwait(false)) + { + if (update.Contents.OfType().Any()) + { + held.Add(update); + continue; + } + + yield return update; + } + + List<(ToolApprovalRequestContent Request, string Reason)> denied = DeniedOf(held.SelectMany(static update => update.Contents), options); + + if (denied.Count == 0) + { + foreach (AgentResponseUpdate update in held) + { + yield return update; + } + + yield break; + } + + if (CalledTheModel(before, options) && answered++ == MaxRounds) + { + throw Exhausted(denied, options); + } + + foreach (AgentResponseUpdate update in held) + { + List rest = Without(update.Contents, denied); + if (rest.Count > 0) + { + update.Contents = rest; + yield return update; + } + } + + input = [Answer(denied, options)]; + } + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/CallGateChain.cs b/src/AgentCore.Application/Hooks/Layers/CallGateChain.cs new file mode 100644 index 00000000..2e03b6cc --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/CallGateChain.cs @@ -0,0 +1,67 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.Application.Hooks.Layers +{ + /// What a transport offers when a call arrives. + /// The entry the call's route serves. + /// The transport's id for the call. + /// The caller's number or address, when known. + /// The number or address that was called, when known. + /// The transport's headers, for example SIP headers. + /// The kind of transport that offered the call. + internal sealed record CallOffer(string Entry, string CallId, string? From, string? To, IReadOnlyDictionary Headers, string Transport); + + /// The call gate's answer: accepted under a conversation id with an optional brief, or refused with a reason. + /// Whether the call is taken. + /// The conversation the call joins, when accepted. + /// Text the agent is told about the call, when accepted. + /// Why the call is refused, when it is. + internal sealed record CallDecision(bool Accepted, string? ConversationId, string? Brief, CallRefusal? Refusal); + + /// The phone path calls this when a transport offers a call. + internal static class CallGateChain + { + private static readonly CallDecision Undecided = new(Accepted: false, ConversationId: null, Brief: null, Refusal: null); + + /// Runs the chain and returns what the call does. + /// The compiled hooks. + /// The call on offer. + /// The call's token. + /// How long each hook may take: providers.conversation.answerSeconds, or for 5 s. + internal static async ValueTask DecideAsync(HookRuntime hooks, CallOffer offer, CancellationToken cancellationToken, TimeSpan? deadline = null) + { + ArgumentNullException.ThrowIfNull(hooks); + ArgumentNullException.ThrowIfNull(offer); + + if (!hooks.Gates.Overrides(GatePoint.BeforeCall)) + { + return new CallDecision(Accepted: true, offer.CallId, Brief: null, Refusal: null); + } + + HookScope scope = HookScopes.ForGate(conversationId: null, offer.Entry, hooks.Timers.GetUtcNow()); + + CallDecision decision = await hooks.Gates.RunAsync( + GatePoint.BeforeCall, + scope, + Undecided, + _ => new CallGate(scope, offer.Entry, offer.CallId, offer.From, offer.To, offer.Headers, offer.Transport), + static (hook, gate, token) => hook.BeforeCallAsync(gate, token), + static (state, gate) => + { + if (gate.AcceptedConversationId is { } accepted) + { + return new CallDecision(Accepted: true, accepted, gate.Brief, Refusal: null); + } + + return gate.Refusal is { } refusal ? new CallDecision(Accepted: false, ConversationId: null, Brief: null, refusal) : state; + }, + static _ => new CallDecision(Accepted: false, ConversationId: null, Brief: null, CallRefusal.Unavailable), + hooks.RaiseHostFault, + cancellationToken, + deadline).ConfigureAwait(false); + + return decision == Undecided ? decision with { Refusal = CallRefusal.Unavailable } : decision; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs b/src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs new file mode 100644 index 00000000..66bdc30e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.Application.Hooks.Layers +{ + /// What a request on an AgentCore route offers the entry gate. + /// The authenticated user, or when the request is anonymous. + /// The HTTP request's headers. A header with several values joins them with a comma. + /// The route pattern that matched. + /// The entry the URL named, or when the route has no entry segment. + /// How the request arrived. + internal sealed record EntryOffer(ClaimsPrincipal? User, IReadOnlyDictionary Headers, string Route, string? RequestedEntry, EntryTransport Transport); + + /// Picks the entry a request runs. No decision takes the URL's entry; none there refuses. + internal static class EntryGateChain + { + private sealed record Choice(string? Entry, bool Refused); + + /// Runs the chain and returns the entry the request runs. With no hook overriding BeforeEntry it returns the URL's entry. + /// The compiled hooks. + /// The request on offer. + /// The request's token. + /// The entry key, or when a hook refused or no entry is named. + internal static async ValueTask ChooseAsync(HookRuntime hooks, EntryOffer offer, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(hooks); + ArgumentNullException.ThrowIfNull(offer); + + HookScope scope = HookScopes.ForGate(conversationId: null, offer.RequestedEntry, hooks.Timers.GetUtcNow()); + Choice choice = await hooks.Gates.RunAsync( + GatePoint.BeforeEntry, + scope, + new Choice(Entry: null, Refused: false), + _ => new EntryGate(scope, offer.User, offer.Headers, offer.Route, offer.RequestedEntry, offer.Transport), + static (hook, gate, token) => hook.BeforeEntryAsync(gate, token), + static (state, gate) => + { + if (gate.Chosen is { } chosen) + { + return new Choice(chosen, Refused: false); + } + + return gate.Refused ? new Choice(Entry: null, Refused: true) : state; + }, + static _ => new Choice(Entry: null, Refused: true), + hooks.RaiseHostFault, + cancellationToken).ConfigureAwait(false); + + return choice.Refused ? null : choice.Entry ?? offer.RequestedEntry; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs b/src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs new file mode 100644 index 00000000..d5ca4aa5 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs @@ -0,0 +1,85 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Hooks.Layers +{ + /// + /// BeforeCompaction at the one point that means "AgentCore is about to compact": the strategy's first request to the + /// summariser. The chain runs once; its decision answers every request of this compaction. + /// + internal sealed class GatedSummariser(IChatClient inner, HookRuntime hooks, TurnInvocation? turn, int messageCount) : DelegatingChatClient(inner) + { + private sealed record Decision(bool Cancel, string? Summary); + + private Decision? _decision; + + /// Gets whether the strategy asked for a summary at all. + internal bool Asked => _decision is not null; + + /// Gets whether a hook cancelled this compaction. + internal bool Cancelled => _decision?.Cancel == true; + + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return await AnswerAsync(cancellationToken).ConfigureAwait(false) + ?? await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (await AnswerAsync(cancellationToken).ConfigureAwait(false) is { } answer) + { + foreach (ChatResponseUpdate update in answer.ToChatResponseUpdates()) + { + yield return update; + } + + yield break; + } + + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private async ValueTask AnswerAsync(CancellationToken cancellationToken) + { + _decision ??= await DecideAsync(cancellationToken).ConfigureAwait(false); + + return _decision switch + { + { Cancel: true } => new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty)), + { Summary: { } text } => new ChatResponse(new ChatMessage(ChatRole.Assistant, text)), + _ => null, + }; + } + + private async ValueTask DecideAsync(CancellationToken cancellationToken) + { + if (turn?.Hooks is not { } raiser || !hooks.Gates.Overrides(GatePoint.BeforeCompaction)) + { + return new Decision(Cancel: false, Summary: null); + } + + HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage); + IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal); + + return await hooks.Gates.RunAsync( + GatePoint.BeforeCompaction, + scope, + new Decision(Cancel: false, Summary: null), + _ => new CompactionGate(scope, messageCount, items), + static (hook, gate, token) => hook.BeforeCompactionAsync(gate, token), + static (state, gate) => new Decision(state.Cancel || gate.Cancelled, gate.Summary ?? state.Summary), + static state => state with { Cancel = true }, + raiser.RaiseFault, + cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs b/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs new file mode 100644 index 00000000..b25f2627 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs @@ -0,0 +1,235 @@ +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Hooks.Layers +{ + /// + /// BeforeModel, AfterModel and AfterModelFailed over each model round trip of an agent, directly inside , so a + /// hook sees what the model sees. The function-invoking client sits above, so every round passes here. + /// + /// The vendor's client. + /// The hooks of this compile. + internal sealed class ModelHookChatClient(IChatClient inner, HookRuntime hooks) : DelegatingChatClient(inner) + { + /// The most times one round is retried after a hook's Retry. + internal const int MaxRetries = 2; + + private readonly GateRunner _gates = hooks.Gates; + + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + if (Turn() is not { Hooks: { } session } turn) + { + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + ModelRound round = new(session, turn); + ModelRoundGates.Asked asked = await ModelRoundGates.BeforeAsync(_gates, round, messages, options, cancellationToken).ConfigureAwait(false); + + ChatResponse response; + Exception? recovered = null; + if (asked.Answer is { } answered) + { + response = answered; + } + else + { + for (int attempt = 0; ; attempt++) + { + try + { + response = await base.GetResponseAsync(asked.Messages, asked.Options, cancellationToken).ConfigureAwait(false); + break; + } + catch (Exception failure) when (failure is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + ModelRoundGates.Recovery recovery = await ModelRoundGates + .FailedAsync(_gates, round, attempt, failure, canRetry: attempt < MaxRetries, cancellationToken).ConfigureAwait(false); + + if (recovery.Retrying) + { + continue; + } + + if (recovery.Answer is { } substitute) + { + response = substitute; + recovered = failure; + break; + } + + round.Called(response: null, asked.Options, failure); + throw; + } + } + } + + response = await ModelRoundGates.AfterAsync(_gates, round, response, cancellationToken).ConfigureAwait(false); + if (asked.Answer is null) + { + round.Called(response, asked.Options, recovered); + } + + return response; + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Turn() is not { Hooks: { } session } turn) + { + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + + yield break; + } + + ModelRound round = new(session, turn); + ModelRoundGates.Asked asked = await ModelRoundGates.BeforeAsync(_gates, round, messages, options, cancellationToken).ConfigureAwait(false); + + // A round is buffered only when some hook may replace it. + bool buffer = _gates.Overrides(GatePoint.AfterModel); + bool collect = buffer || session.Wants(); + List seen = []; + bool yielded = false; + Exception? recovered = null; + + if (asked.Answer is { } answered) + { + seen.AddRange(answered.ToChatResponseUpdates()); + } + else + { + for (int attempt = 0; ; attempt++) + { + Exception? failure = null; + IAsyncEnumerator? stream = null; + try + { + stream = base.GetStreamingResponseAsync(asked.Messages, asked.Options, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (Exception thrown) when (thrown is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + failure = thrown; + } + + if (stream is not null) + { + try + { + while (true) + { + bool more; + try + { + more = await stream.MoveNextAsync().ConfigureAwait(false); + } + catch (Exception thrown) when (thrown is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + failure = thrown; + break; + } + + if (!more) + { + break; + } + + if (collect) + { + seen.Add(stream.Current); + } + + if (!buffer) + { + // Set just before the yield, so it is true exactly when the caller has something. + yielded = true; + yield return stream.Current; + } + } + } + finally + { + await stream.DisposeAsync().ConfigureAwait(false); + } + } + + if (failure is null) + { + break; + } + + ModelRoundGates.Recovery recovery = await ModelRoundGates + .FailedAsync(_gates, round, attempt, failure, canRetry: !yielded && attempt < MaxRetries, cancellationToken).ConfigureAwait(false); + + if (recovery.Retrying) + { + seen.Clear(); + continue; + } + + if (recovery.Answer is { } substitute) + { + recovered = failure; + seen.Clear(); + seen.AddRange(substitute.ToChatResponseUpdates()); + if (yielded) + { + // Without an id of its own the answer would merge into the partial message the caller holds. + string messageId = Guid.NewGuid().ToString("N"); + foreach (ChatResponseUpdate update in seen) + { + update.MessageId ??= messageId; + } + } + + if (!buffer) + { + foreach (ChatResponseUpdate update in seen) + { + yielded = true; + yield return update; + } + } + + break; + } + + round.Called(seen.Count > 0 ? seen.ToChatResponse() : null, asked.Options, failure); + ExceptionDispatchInfo.Throw(failure); + } + } + + if (!buffer && asked.Answer is null) + { + round.Called(collect ? seen.ToChatResponse() : null, asked.Options, recovered); + yield break; + } + + ChatResponse collected = seen.ToChatResponse(); + ChatResponse final = await ModelRoundGates.AfterAsync(_gates, round, collected, cancellationToken).ConfigureAwait(false); + if (asked.Answer is null) + { + round.Called(final, asked.Options, recovered); + } + + foreach (ChatResponseUpdate update in ReferenceEquals(final, collected) ? seen : [.. final.ToChatResponseUpdates()]) + { + yield return update; + } + } + + private static TurnInvocation? Turn() => TurnInvocation.From(AIAgent.CurrentRunContext?.RunOptions); + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/ModelRound.cs b/src/AgentCore.Application/Hooks/Layers/ModelRound.cs new file mode 100644 index 00000000..001fd56d --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/ModelRound.cs @@ -0,0 +1,56 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Hooks.Layers +{ + /// One model round trip of a turn, as the model layer reports it. + internal sealed class ModelRound + { + private readonly long _started; + + internal ModelRound(SessionHooks hooks, TurnInvocation turn) + { + Hooks = hooks; + Index = turn.Rounds?.Next() ?? 0; + Scope = hooks.Scope(turn.TurnIndex, turn.Stage); + Items = turn.Items ?? new Dictionary(StringComparer.Ordinal); + RefusalReply = turn.RefusalReply ?? AgentCoreConfiguration.DefaultRefusalReply; + _started = hooks.Time.GetTimestamp(); + } + + internal SessionHooks Hooks { get; } + + internal int Index { get; } + + internal HookScope Scope { get; } + + internal IDictionary Items { get; } + + /// Gets what a gate that fails closed answers in the model's place. + internal string RefusalReply { get; } + + /// Raises for this round, when some hook wants it. + internal void Called(ChatResponse? response, ChatOptions? options, Exception? failure) + { + if (!Hooks.Wants()) + { + return; + } + + UsageDetails? usage = response?.Usage; + _ = Hooks.Raise(new ModelCalled( + Scope, + response?.ModelId ?? options?.ModelId, + Index, + usage?.InputTokenCount, + usage?.OutputTokenCount, + usage?.CachedInputTokenCount, + Hooks.Time.GetElapsedTime(_started), + response?.FinishReason?.Value, + failure is null ? null : $"{failure.GetType().Name}: {failure.Message}")); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs b/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs new file mode 100644 index 00000000..77adef67 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs @@ -0,0 +1,70 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Layers +{ + /// The three model gate chains (BeforeModel, AfterModel, AfterModelFailed) over one round. + internal static class ModelRoundGates + { + internal static ValueTask BeforeAsync( + GateRunner gates, ModelRound round, IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) + { + Asked asked = new(messages as IReadOnlyList ?? [.. messages], options, Answer: null); + return !gates.Overrides(GatePoint.BeforeModel) + ? ValueTask.FromResult(asked) + : gates.RunAsync( + GatePoint.BeforeModel, + round.Scope, + asked, + state => new ModelGate(round.Scope, round.Index, state.Messages, state.Options, round.Items), + static (hook, gate, token) => hook.BeforeModelAsync(gate, token), + static (state, gate) => new Asked(gate.NewMessages ?? state.Messages, gate.NewOptions ?? state.Options, gate.Response ?? state.Answer), + state => state with { Answer = Refusal(round) }, + round.Hooks.RaiseFault, + cancellationToken); + } + + internal static ValueTask AfterAsync(GateRunner gates, ModelRound round, ChatResponse response, CancellationToken cancellationToken) + { + return !gates.Overrides(GatePoint.AfterModel) + ? ValueTask.FromResult(response) + : gates.RunAsync( + GatePoint.AfterModel, + round.Scope, + response, + state => new ModelResultGate(round.Scope, round.Index, state, round.Items), + static (hook, gate, token) => hook.AfterModelAsync(gate, token), + static (state, gate) => gate.Replacement ?? state, + _ => Refusal(round), + round.Hooks.RaiseFault, + cancellationToken); + } + + internal static ValueTask FailedAsync( + GateRunner gates, ModelRound round, int attempt, Exception failure, bool canRetry, CancellationToken cancellationToken) + { + Recovery none = new(Retrying: false, Answer: null); + return !gates.Overrides(GatePoint.AfterModelFailed) + ? ValueTask.FromResult(none) + : gates.RunAsync( + GatePoint.AfterModelFailed, + round.Scope, + none, + _ => new ModelFailureGate(round.Scope, round.Index, attempt, failure, canRetry, round.Items), + static (hook, gate, token) => hook.AfterModelFailedAsync(gate, token), + static (state, gate) => new Recovery(gate.Retrying, gate.Response ?? state.Answer), + static state => state, + round.Hooks.RaiseFault, + cancellationToken); + } + + private static ChatResponse Refusal(ModelRound round) => new(new ChatMessage(ChatRole.Assistant, round.RefusalReply)); + + /// What the model is asked after BeforeModel, or the answer a hook gave in its place. + internal sealed record Asked(IReadOnlyList Messages, ChatOptions? Options, ChatResponse? Answer); + + /// What AfterModelFailed decided: call the model again, or answer for it. + internal sealed record Recovery(bool Retrying, ChatResponse? Answer); + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs b/src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs new file mode 100644 index 00000000..91d9f713 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs @@ -0,0 +1,45 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Hooks.Layers +{ + /// The run has a reply, before the turn is sealed. The loop evaluator in ApplyLoop calls this. + internal static class RunEndGateChain + { + /// The run cap when the agent declares no loop:. + internal const int DefaultMaxIterations = 3; + + /// Runs the chain and returns the message to run again with, or to stop. + /// The compiled hooks. + /// The turn the run belongs to, or outside a conversation turn. + /// The agent that ran, when known. + /// The run's reply text. + /// How many runs of this turn have completed, starting at 1. + /// The turn's token. + internal static async ValueTask ContinueAsync( + HookRuntime hooks, TurnInvocation? turn, string? agentId, string replyText, int iteration, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(hooks); + + if (turn is not { Nested: false, Hooks: { } raiser }) + { + return null; + } + + HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage); + IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal); + + return await hooks.Gates.RunAsync( + GatePoint.AfterRun, + scope, + null, + _ => new RunEndGate(scope, agentId, replyText, iteration, items), + static (hook, gate, token) => hook.AfterRunAsync(gate, token), + static (state, gate) => gate.Continuation ?? state, + static state => state, + raiser.RaiseFault, + cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs b/src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs new file mode 100644 index 00000000..c53ed6dc --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs @@ -0,0 +1,51 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Runtime.Turn; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Layers +{ + /// Once per run, when its context is built. Everything it adds is seen on every round. + internal sealed class RunGateProvider(HookRuntime hooks) : AIContextProvider + { + private sealed record Added(IReadOnlyList Instructions, IReadOnlyList Messages, IReadOnlyList Tools); + + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + // The turn's own run is filed under its session, and a graph participant under its own with a nested + // copy; an agent-as-tool child is found through the run's options and is nested too. + TurnInvocation? filed = TurnRegistry.For(context.Session); + TurnInvocation? turn = filed ?? TurnInvocation.From(AIAgent.CurrentRunContext?.RunOptions); + if (turn?.Hooks is not { } raiser) + { + return new AIContext(); + } + + bool nested = turn.Nested || filed is null; + HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage); + IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal); + string agentId = context.Agent.Name ?? context.Agent.Id; + + Added added = await hooks.Gates.RunAsync( + GatePoint.BeforeRun, + scope, + new Added([], [], []), + _ => new RunGate(scope, agentId, nested, items), + static (hook, gate, token) => hook.BeforeRunAsync(gate, token), + static (state, gate) => new Added([.. state.Instructions, .. gate.Instructions], [.. state.Messages, .. gate.Messages], [.. state.Tools, .. gate.Tools]), + static state => state, + raiser.RaiseFault, + cancellationToken).ConfigureAwait(false); + + return new AIContext + { + Instructions = added.Instructions.Count == 0 ? null : string.Join("\n\n", added.Instructions), + Messages = added.Messages.Count == 0 ? null : added.Messages, + Tools = added.Tools.Count == 0 ? null : added.Tools, + }; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs b/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs new file mode 100644 index 00000000..dc72fd63 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs @@ -0,0 +1,32 @@ +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Hooks.Layers +{ + /// + /// Marks a tool call's outcome beside its arguments, where the wrappers that know it (cache, time limit, + /// pinned-skill redirect) and the tool middleware that reports it both reach it. + /// + internal static class ToolCallOutcomes + { + internal static readonly object CachedKey = new(); + + internal static readonly object TimedOutKey = new(); + + internal static readonly object RedirectedKey = new(); + + internal static void Mark(AIFunctionArguments arguments, object key) + { + arguments.Context ??= new Dictionary(); + arguments.Context[key] = true; + } + + internal static bool Marked(AIFunctionArguments arguments, object key) => + arguments.Context?.ContainsKey(key) == true; + + internal static ToolOutcome Of(AIFunctionArguments arguments) => + Marked(arguments, TimedOutKey) ? ToolOutcome.TimedOut + : Marked(arguments, CachedKey) ? ToolOutcome.Cached + : ToolOutcome.Ok; + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs b/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs new file mode 100644 index 00000000..9ec4b75e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs @@ -0,0 +1,245 @@ +using System.Globalization; +using System.Text.Json; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Skills; +using AgentCore.Application.Tools; +using AgentCore.Domain.Audit; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Hooks.Layers +{ + /// + /// The one function middleware of an agent: BeforeTool, AfterTool and AfterToolFailed, and one per call. + /// MAF runs function middleware innermost-first, so AgentCore registers this one and orders the hooks itself. + /// + internal static class ToolHookMiddleware + { + /// The most times one call is run again after a hook's Retry. + internal const int MaxRetries = 2; + + private static readonly string StoppedAtEnd = string.Create( + CultureInfo.InvariantCulture, $"stopped {ConversationEnding.ToolGrace.TotalSeconds:0} s after the conversation ended."); + + internal static AIAgent Apply(AIAgent agent, HookRuntime hooks) + { + AIAgent gated = new AIAgentBuilder(agent) + .Use((_, context, next, cancellationToken) => InvokeAsync(hooks.Gates, context, next, cancellationToken)) + .Build(); + + // MAF 1.21.0's function middleware wraps the ChatClientFactory of the options it is handed, in place. + // LoopAgent hands every round the same options, so without a fresh copy per run round k would run + // this middleware k times per call. + return new AIAgentBuilder(gated) + .Use( + (messages, session, options, inner, cancellationToken) => inner.RunAsync(messages, session, Fresh(options), cancellationToken), + (messages, session, options, inner, cancellationToken) => inner.RunStreamingAsync(messages, session, Fresh(options), cancellationToken)) + .Build(); + } + + private static AgentRunOptions? Fresh(AgentRunOptions? options) => + options is ChatClientAgentRunOptions run ? run.Clone() : options; + + private sealed record Before(Dictionary Arguments, bool Blocked, bool Responded, object? Result, bool EndLoop); + + private sealed record After(object? Result, bool EndLoop); + + private sealed record Failed(bool Retrying, bool Responded, object? Result); + + /// What every gate and the notice of one call share. + private sealed record Call( + GateRunner Gates, + SessionHooks Hooks, + HookScope Scope, + string Name, + string CallId, + IDictionary Items, + long Started); + + private static async ValueTask InvokeAsync( + GateRunner gates, + FunctionInvocationContext context, + Func> next, + CancellationToken cancellationToken) + { + TurnInvocation? turn = TurnInvocation.FiledIn(context.Arguments) ?? TurnInvocation.From(context.Options); + if (turn?.Hooks is not { } hooks) + { + return await next(context, cancellationToken).ConfigureAwait(false); + } + + Call call = new( + gates, + hooks, + hooks.Scope(turn.TurnIndex, turn.Stage), + context.CallContent.Name, + context.CallContent.CallId, + turn.Items ?? new Dictionary(StringComparer.Ordinal), + hooks.Time.GetTimestamp()); + + if (gates.Overrides(GatePoint.BeforeTool)) + { + Dictionary asked = new(context.Arguments, StringComparer.Ordinal); + Before before = await BeforeAsync(call, asked, cancellationToken).ConfigureAwait(false); + context.Terminate |= before.EndLoop; + + if (before.Blocked || before.Responded) + { + Called(call, before.Blocked ? ToolOutcome.Blocked : ToolOutcome.Responded, fatal: false, kind: null, failure: null); + return before.Result; + } + + if (!ReferenceEquals(before.Arguments, asked)) + { + context.Arguments = Replaced(context.Arguments, before.Arguments); + } + } + + object? result; + for (int attempt = 0; ; attempt++) + { + try + { + result = await next(context, cancellationToken).ConfigureAwait(false); + break; + } + catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + Failed failed = await FailedAsync(call, attempt, exception, cancellationToken).ConfigureAwait(false); + if (failed.Retrying) + { + continue; + } + + string message = $"{exception.GetType().Name}: {exception.Message}"; + if (failed.Responded) + { + Called(call, ToolOutcome.Responded, fatal: false, ToolFailureKind.Faulted, message); + return failed.Result; + } + + Called(call, ToolOutcome.Failed, AuditingFunctionInvokingChatClient.IsBeyondTheModel(exception), ToolFailureKind.Faulted, message); + throw; + } + catch (OperationCanceledException) when (cancellationToken == turn.ToolStop) + { + // The end backstop stopped a call that ran: the audit shows it. + Called(call, ToolOutcome.Failed, fatal: true, ToolFailureKind.Faulted, StoppedAtEnd); + throw; + } + } + + if (gates.Overrides(GatePoint.AfterTool)) + { + After after = await AfterAsync(call, result, cancellationToken).ConfigureAwait(false); + result = after.Result; + context.Terminate |= after.EndLoop; + } + + ToolOutcome outcome = ToolCallOutcomes.Of(context.Arguments); + Called(call, outcome, fatal: false, kind: null, outcome == ToolOutcome.TimedOut ? "the tool did not answer within its time limit." : null); + RaiseSkillLoaded(call, context.Arguments, result); + + return result; + } + + private static ValueTask BeforeAsync(Call call, Dictionary asked, CancellationToken cancellationToken) + { + return call.Gates.RunAsync( + GatePoint.BeforeTool, + call.Scope, + new Before(asked, Blocked: false, Responded: false, Result: null, EndLoop: false), + state => new ToolGate(call.Scope, call.Name, call.CallId, state.Arguments, call.Items), + static (hook, gate, token) => hook.BeforeToolAsync(gate, token), + static (state, gate) => state with + { + // A copy: the hook keeps its own dictionary and may change it after the gate closed. + Arguments = gate.NewArguments is { } replaced ? new Dictionary(replaced, StringComparer.Ordinal) : state.Arguments, + Blocked = gate.Blocked, + Responded = gate.Responded, + Result = gate.Blocked || gate.Responded ? gate.Result : state.Result, + EndLoop = state.EndLoop || gate.EndsLoop, + }, + state => state with { Blocked = true, Result = ToolErrorResult.Create(call.Name, "a hook refused this call.") }, + call.Hooks.RaiseFault, + cancellationToken); + } + + private static async ValueTask FailedAsync(Call call, int attempt, Exception exception, CancellationToken cancellationToken) + { + if (!call.Gates.Overrides(GatePoint.AfterToolFailed)) + { + return new Failed(Retrying: false, Responded: false, Result: null); + } + + return await call.Gates.RunAsync( + GatePoint.AfterToolFailed, + call.Scope, + new Failed(Retrying: false, Responded: false, Result: null), + _ => new ToolFailureGate(call.Scope, call.Name, call.CallId, attempt, exception, attempt < MaxRetries, call.Items), + static (hook, gate, token) => hook.AfterToolFailedAsync(gate, token), + static (state, gate) => new Failed(gate.Retrying, gate.Responded, gate.Responded ? gate.Result : state.Result), + static state => state, + call.Hooks.RaiseFault, + cancellationToken).ConfigureAwait(false); + } + + private static ValueTask AfterAsync(Call call, object? result, CancellationToken cancellationToken) + { + return call.Gates.RunAsync( + GatePoint.AfterTool, + call.Scope, + new After(result, EndLoop: false), + state => new ToolResultGate(call.Scope, call.Name, call.CallId, state.Result, call.Items), + static (hook, gate, token) => hook.AfterToolAsync(gate, token), + static (state, gate) => new After(gate.Replaced ? gate.NewResult : state.Result, state.EndLoop || gate.EndsLoop), + state => state with { Result = "a hook withheld this result." }, + call.Hooks.RaiseFault, + cancellationToken); + } + + private static void Called(Call call, ToolOutcome outcome, bool fatal, ToolFailureKind? kind, string? failure) + { + if (call.Hooks.Wants()) + { + _ = call.Hooks.Raise(new ToolCalled( + call.Scope, call.Name, call.CallId, call.Hooks.Time.GetElapsedTime(call.Started), outcome, fatal, kind, failure)); + } + } + + // A load_skill the pinned-skill redirect answered, or that MAF answered "not + // found", loaded nothing. + private static void RaiseSkillLoaded(Call call, AIFunctionArguments arguments, object? result) + { + if (string.Equals(call.Name, AgentSkillsProvider.LoadSkillToolName, StringComparison.Ordinal) + && !ToolCallOutcomes.Marked(arguments, ToolCallOutcomes.RedirectedKey) + && PinnedSkillRedirect.ReadName(arguments) is { } skill + && !SkillNotFound(skill, result) + && call.Hooks.Wants()) + { + _ = call.Hooks.Raise(new SkillLoaded(call.Scope, skill, Pinned: false)); + } + } + + // MAF's load_skill answers an unknown name with this text (SkillsProviderFactoryTests pins it), serialized. + private static bool SkillNotFound(string skill, object? result) => + result is JsonElement { ValueKind: JsonValueKind.String } text + && string.Equals(text.GetString(), $"Error: Skill '{skill}' not found.", StringComparison.Ordinal); + + // A new object, because the old one wraps the model's own call record, which is stored and sent back. + // It shares the old Context: that is where the turn is filed and where the wrappers leave their marks. + private static AIFunctionArguments Replaced(AIFunctionArguments arguments, Dictionary replacement) + { + return new AIFunctionArguments(replacement) + { + Context = arguments.Context, + Services = arguments.Services, + }; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs b/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs new file mode 100644 index 00000000..5769f065 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs @@ -0,0 +1,97 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Hooks.Layers +{ + /// + /// Runs between the seal and moderation. It runs once per conversation turn, never for a nested run, and before + /// moderation and the model see the caller's words. A hook that fails closed blocks the turn with refusalReply. + /// + internal sealed class TurnGateAgent(AIAgent inner, HookRuntime hooks, string refusalReply) : DelegatingAIAgent(inner) + { + private sealed record Decision(string Text, string? Block, IReadOnlyList Context); + + 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)) + { + 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; + } + + ChatMessage user = turn.User!; + SessionHooks raiser = turn.Hooks!; + HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage); + IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal); + + Decision decision = await hooks.Gates.RunAsync( + GatePoint.BeforeTurn, + scope, + new Decision(user.Text, Block: null, Context: []), + state => new TurnGate(scope, state.Text, items), + static (hook, gate, token) => hook.BeforeTurnAsync(gate, token), + static (state, gate) => new Decision(gate.Input ?? state.Text, gate.BlockReply ?? state.Block, [.. state.Context, .. gate.AddedContext]), + state => state with { Block = refusalReply }, + raiser.RaiseFault, + cancellationToken).ConfigureAwait(false); + + if (decision.Block is { } reply) + { + AgentResponseUpdate blocked = new(ChatRole.Assistant, reply); + (blocked.AdditionalProperties ??= []).Add( + new TurnDisposition(Moderation: null, FlaggedCategories: null, FallbackCause.None, FallbackFault: null, ModerationReason: null) { Blocked = true }); + yield return blocked; + yield break; + } + + if (!string.Equals(decision.Text, user.Text, StringComparison.Ordinal)) + { + // In place: the seal commits this same message object (FiledTurn.User). + user.Contents = [new TextContent(decision.Text), .. user.Contents.Where(static content => content is not TextContent)]; + } + + foreach (string note in decision.Context) + { + turn.AddedContext?.Enqueue(note); + } + + await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private static TurnInvocation? Filed(AgentRunOptions? options) => + TurnInvocation.From(options) is { Nested: false, User: not null, Hooks: not null } turn ? turn : null; + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs b/src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs new file mode 100644 index 00000000..b8d114a7 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs @@ -0,0 +1,15 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Who answered an approval, or who will. + public enum ApprovalBy + { + /// A hook. + Hook, + + /// A person. + Human, + + /// A rule of the entry. + Rule, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs b/src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs new file mode 100644 index 00000000..825a267e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A tool approval was asked or answered. + /// Where and when it happened. + /// The tool awaiting or holding the approval. + /// The function call id (FunctionCallContent.CallId), for , and alike. + /// Where the approval stands. + /// Who answered the approval, or who will. + /// The reason given, or . + public sealed record ApprovalChanged( + HookScope Scope, + string ToolName, + string CallId, + ApprovalState State, + ApprovalBy By, + string? Reason) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ApprovalState.cs b/src/AgentCore.Application/Hooks/Notices/ApprovalState.cs new file mode 100644 index 00000000..b6eae953 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ApprovalState.cs @@ -0,0 +1,15 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Where an approval stands. + public enum ApprovalState + { + /// The approval was requested. + Asked, + + /// The call was approved. + Approved, + + /// The call was denied. + Denied, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/CallEnd.cs b/src/AgentCore.Application/Hooks/Notices/CallEnd.cs new file mode 100644 index 00000000..f18694a9 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/CallEnd.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The call part of a conversation that ended on the phone. + /// The transport's own id of the call. + /// How long the call lasted, in seconds. + /// The transport's word for why the call ended, or . + public sealed record CallEnd(string CallId, double Seconds, string? Cause); +} diff --git a/src/AgentCore.Application/Hooks/Notices/CallEndReason.cs b/src/AgentCore.Application/Hooks/Notices/CallEndReason.cs new file mode 100644 index 00000000..c955642f --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/CallEndReason.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// How a call left its conversation. + public enum CallEndReason + { + /// The call ended together with its conversation, which raises . + Ended, + + /// A newer connection with the same call id took the call over; the conversation goes on under it. + Replaced, + + /// Another call took the conversation after it unloaded; that call's conversation is left alone. + Lost, + + /// The call left without ending its conversation, which was closed instead. + Closed, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/CallEnded.cs b/src/AgentCore.Application/Hooks/Notices/CallEnded.cs new file mode 100644 index 00000000..4ef40086 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/CallEnded.cs @@ -0,0 +1,19 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// + /// A started call left, once per call, however it left. It never replaces , which + /// stays once per conversation; this one also reaches the hooks after that end. + /// + /// Where and when it happened. + /// The transport's own id of the call. + /// How long the call lasted, in seconds, up to when it began to leave. + /// The transport's word for why the call ended, or . + /// How the call left its conversation. + public sealed record CallEnded( + HookScope Scope, + string CallId, + double Seconds, + string? Cause, + CallEndReason Reason) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/CallStarted.cs b/src/AgentCore.Application/Hooks/Notices/CallStarted.cs new file mode 100644 index 00000000..00834fdd --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/CallStarted.cs @@ -0,0 +1,16 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A call was accepted and its session is open. + /// Where and when it happened. + /// The transport's own id of the call. + /// The caller's number, or . + /// The number called, or . + /// The kind of transport that carried the call. + public sealed record CallStarted( + HookScope Scope, + string CallId, + string? From, + string? To, + string Transport) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/Compacted.cs b/src/AgentCore.Application/Hooks/Notices/Compacted.cs new file mode 100644 index 00000000..0cb83407 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/Compacted.cs @@ -0,0 +1,14 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A compaction finished or failed. + /// Where and when it happened. + /// The message count before. + /// The message count after. + /// How it ended. + public sealed record Compacted( + HookScope Scope, + int MessagesBefore, + int MessagesAfter, + CompactionOutcome Outcome) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs b/src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs new file mode 100644 index 00000000..c2952698 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// How a compaction ended. + public enum CompactionOutcome + { + /// The history shrank. + Compacted, + + /// The history stayed as it was. + Unchanged, + + /// The compaction faulted. + Failed, + + /// The compaction was cancelled. + Cancelled, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs b/src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs new file mode 100644 index 00000000..fd4d2f0e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs @@ -0,0 +1,16 @@ +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Hooks.Notices +{ + /// The conversation ended. Raised once per conversation. + /// Where and when it happened. + /// Why it ended. + /// The terminal stage the machine stopped in, or null when the conversation ended for another reason. + /// The call that carried the conversation, or null. Set by the phone path. + public sealed record ConversationEnded( + HookScope Scope, + ConversationEndReason Reason, + string? TerminalStage, + CallEnd? Call) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs b/src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs new file mode 100644 index 00000000..98eaec83 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs @@ -0,0 +1,21 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// How a session found its conversation. + public enum ConversationOrigin + { + /// The store held nothing. + New, + + /// The store held rows or state. + Resumed, + + /// This process held the conversation in an earlier session. + Reloaded, + + /// + /// The session ended or refused a turn before its store was opened, so whether the conversation was new or + /// resumed is unknown. + /// + Unopened, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs b/src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs new file mode 100644 index 00000000..1ab0ad35 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs @@ -0,0 +1,7 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The store opened the conversation. + /// Where and when it happened. + /// How the session found the conversation. + public sealed record ConversationStarted(HookScope Scope, ConversationOrigin Origin) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs b/src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs new file mode 100644 index 00000000..cfbfbf8f --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs @@ -0,0 +1,7 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The session left memory by an idle unload or a close; the conversation lives on in the store. + /// Where and when it happened. + /// Why the session left memory. + public sealed record ConversationUnloaded(HookScope Scope, UnloadCause Cause) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/Fault.cs b/src/AgentCore.Application/Hooks/Notices/Fault.cs new file mode 100644 index 00000000..b9dca25c --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/Fault.cs @@ -0,0 +1,9 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A non-fatal fault happened. + /// Where and when it happened. + /// Which fault. + /// What went wrong. + /// The exception behind the failure, for a log's stack trace, or null. + public sealed record Fault(HookScope Scope, FaultKind Kind, string Message, Exception? Cause) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/FaultKind.cs b/src/AgentCore.Application/Hooks/Notices/FaultKind.cs new file mode 100644 index 00000000..9df28584 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/FaultKind.cs @@ -0,0 +1,30 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A non-fatal fault. + public enum FaultKind + { + /// Writing the transcript failed. + TranscriptWriteFailed, + + /// Resyncing the transcript failed. + TranscriptResyncFailed, + + /// Restoring the conversation state succeeded in part. + StateRestorePartial, + + /// Extracting slots from the turn failed. + ExtractionFailed, + + /// The conversation's busy mark lapsed while a turn still ran, and another session took it. + BusyMarkLost, + + /// A hook threw or ran past its deadline. + HookFailed, + + /// Putting or renewing the conversation's busy mark failed in the store. + BusyMarkFailed, + + /// A hook kept denying tool calls past the approval layer's round cap, so the turn ended as a fault instead of asking a person. + DenialRoundsExhausted, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/FilePublished.cs b/src/AgentCore.Application/Hooks/Notices/FilePublished.cs new file mode 100644 index 00000000..d814bc99 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/FilePublished.cs @@ -0,0 +1,16 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// file.publish stored a file. + /// Where and when it happened. + /// The file's name. + /// The file's length in bytes. + /// The file's media type. + /// ownerId/name of the stored blob. + public sealed record FilePublished( + HookScope Scope, + string Name, + long Length, + string MediaType, + string BlobKey) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/HostStarted.cs b/src/AgentCore.Application/Hooks/Notices/HostStarted.cs new file mode 100644 index 00000000..ed8d6638 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/HostStarted.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Boot finished: the host is ready to serve. + /// Where and when it happened. + /// The names of the entries the host serves. + /// How many tools the host registered. + public sealed record HostStarted(HookScope Scope, IReadOnlyList Entries, int ToolCount) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/HostStopping.cs b/src/AgentCore.Application/Hooks/Notices/HostStopping.cs new file mode 100644 index 00000000..3315394a --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/HostStopping.cs @@ -0,0 +1,6 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Shutdown began. + /// Where and when it happened. + public sealed record HostStopping(HookScope Scope) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/InputModerated.cs b/src/AgentCore.Application/Hooks/Notices/InputModerated.cs new file mode 100644 index 00000000..b927b126 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/InputModerated.cs @@ -0,0 +1,14 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Moderation decided about the caller's words. + /// Where and when it happened. + /// What moderation decided. + /// The endpoint's categories in its own order; empty unless . + /// Why there was no verdict when is , otherwise . + public sealed record InputModerated( + HookScope Scope, + InputVerdict Verdict, + IReadOnlyList Categories, + ModerationUnavailableReason? Reason) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/InputVerdict.cs b/src/AgentCore.Application/Hooks/Notices/InputVerdict.cs new file mode 100644 index 00000000..c9536591 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/InputVerdict.cs @@ -0,0 +1,15 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// What moderation decided about the caller's words. + public enum InputVerdict + { + /// Moderation passed the words. + Clean, + + /// Moderation flagged the words. + Flagged, + + /// Moderation gave no verdict. + Unavailable, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs b/src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs new file mode 100644 index 00000000..586c94e6 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs @@ -0,0 +1,20 @@ +using AgentCore.Domain.Knowledge; + +namespace AgentCore.Application.Hooks.Notices +{ + /// A knowledge search finished. + /// Where and when it happened. + /// The query text. The text is the caller's conversation text: a hook that logs it logs personal data. + /// The scope the search ran under, or . + /// How many hits came back. + /// How long the search took. + /// The failure message, or when the search succeeded. + public sealed record KnowledgeSearched( + HookScope Scope, + string Query, + KnowledgeScope? SearchScope, + int Hits, + TimeSpan Latency, + string? Failure) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs b/src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs new file mode 100644 index 00000000..d755c083 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The four signed-off voice latency readings. + public enum LatencyMetric + { + /// From the start of one model step to its first text or tool call. One reading per step. + TimeToFirstToken, + + /// From the user's final words to the first text of the reply handed to the output. + TimeToFirstSpeech, + + /// From the user's final words to the end of the last step of a reply nothing cut short. A cut reply gets no reading. + TimeToReplyEnd, + + /// From the transport reporting a barge-in to every speech it interrupted being done. + BargeIn, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/LineSpoken.cs b/src/AgentCore.Application/Hooks/Notices/LineSpoken.cs new file mode 100644 index 00000000..f11572a8 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/LineSpoken.cs @@ -0,0 +1,16 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A spoken line closed. + /// Where and when it happened. + /// Who said it. + /// The words said. The text is the caller's conversation text: a hook that logs it logs personal data. + /// When the line began. + /// When the line ended. + public sealed record LineSpoken( + HookScope Scope, + Speaker Speaker, + string Text, + DateTimeOffset StartedAt, + DateTimeOffset EndedAt) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ModelCalled.cs b/src/AgentCore.Application/Hooks/Notices/ModelCalled.cs new file mode 100644 index 00000000..70b2434e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ModelCalled.cs @@ -0,0 +1,30 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// + /// One model round trip ended. A round a hook answered never reached the + /// model, so it raises none. + /// + /// Where and when it happened. + /// The model that answered, or when unknown. + /// The round within the turn. + /// Input tokens, or when the provider reported none. + /// Output tokens, or when the provider reported none. + /// Cached input tokens, or when the provider reported none. + /// How long the round trip took. + /// The provider's finish reason, or . + /// + /// The failure message, or when the model answered. A round an + /// hook answered carries the model's failure it answered for. + /// + public sealed record ModelCalled( + HookScope Scope, + string? ModelId, + int Round, + long? InputTokens, + long? OutputTokens, + long? CachedInputTokens, + TimeSpan Latency, + string? FinishReason, + string? Failure) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs b/src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs new file mode 100644 index 00000000..226741f5 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Why moderation had no verdict. + public enum ModerationUnavailableReason + { + /// The moderation call ran past its limit. + TimedOut, + + /// The moderation call threw. + Threw, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/ReplyCut.cs b/src/AgentCore.Application/Hooks/Notices/ReplyCut.cs new file mode 100644 index 00000000..4c1375ca --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ReplyCut.cs @@ -0,0 +1,14 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The caller spoke over the reply: a cut or a recut. + /// Where and when it happened. + /// The words the caller heard before the cut. The text is the caller's conversation text: a hook that logs it logs personal data. + /// How long the reply played, or when unknown. + /// The of the notice this cut amends. + public sealed record ReplyCut( + HookScope Scope, + string HeardText, + TimeSpan? Played, + Guid AmendsEventId) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs b/src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs new file mode 100644 index 00000000..251bf673 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// An output update left the turn. Raised only when a hook overrides its handler. + /// Where and when it happened. + /// The update's text. The text is the caller's conversation text: a hook that logs it logs personal data. + /// The update's author, or . + public sealed record ReplyUpdated(HookScope Scope, string Text, string? Author) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs b/src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs new file mode 100644 index 00000000..8530ee64 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs @@ -0,0 +1,7 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The hourly retention sweep ran. + /// Where and when it happened. + /// How many rows the sweep deleted. + public sealed record RetentionSwept(HookScope Scope, int Deleted) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs b/src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs new file mode 100644 index 00000000..3d66d2e3 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A skill was pinned or loaded. + /// Where and when it happened. + /// The skill's name. + /// Whether the entry pins the skill, as opposed to the model loading it. + public sealed record SkillLoaded(HookScope Scope, string Name, bool Pinned) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/Speaker.cs b/src/AgentCore.Application/Hooks/Notices/Speaker.cs new file mode 100644 index 00000000..fb8f7e59 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/Speaker.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Who said a spoken line. + public enum Speaker + { + /// The person on the call. + Caller, + + /// The agent. + Agent, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/StageChanged.cs b/src/AgentCore.Application/Hooks/Notices/StageChanged.cs new file mode 100644 index 00000000..6dbd4b8e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/StageChanged.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The stage machine moved. + /// Where and when it happened. + /// The stage before. + /// The stage after. + public sealed record StageChanged(HookScope Scope, string Before, string After) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs b/src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs new file mode 100644 index 00000000..399b1a98 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs @@ -0,0 +1,19 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// An agent-as-tool run ended. + /// Where and when it happened. + /// + /// The function call id of the tool call that started it, or . For an agent an agent-as-tool + /// started (depth 2 or more), it is the id of the outermost tool call, the one the turn's own agent made. + /// + /// The agent that ran as the tool. + /// How the run ended. + /// How long the run took. + public sealed record SubagentEnded( + HookScope Scope, + string? ParentToolCallId, + string AgentId, + SubagentOutcome Outcome, + TimeSpan Duration) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs b/src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs new file mode 100644 index 00000000..b86e2553 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs @@ -0,0 +1,15 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// How an agent-as-tool run ended. + public enum SubagentOutcome + { + /// The sub-agent returned an answer. + Answered, + + /// The sub-agent faulted. + Faulted, + + /// The sub-agent was cancelled. + Cancelled, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs b/src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs new file mode 100644 index 00000000..3bbcbaff --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs @@ -0,0 +1,11 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// An agent-as-tool run began. + /// Where and when it happened. + /// + /// The function call id of the tool call that started it, or . For an agent an agent-as-tool + /// started (depth 2 or more), it is the id of the outermost tool call, the one the turn's own agent made. + /// + /// The agent that runs as the tool. + public sealed record SubagentStarted(HookScope Scope, string? ParentToolCallId, string AgentId) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ToolCalled.cs b/src/AgentCore.Application/Hooks/Notices/ToolCalled.cs new file mode 100644 index 00000000..b9e6f121 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ToolCalled.cs @@ -0,0 +1,24 @@ +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Hooks.Notices +{ + /// One tool call ended. Raised once per call. + /// Where and when it happened. + /// The tool's name. + /// The function call id. + /// How long the call took. + /// How it ended. + /// Whether the fault was one the model cannot answer, so the turn speaks the fallback. False for every outcome but . + /// The kind of failure, or when the call did not fail. + /// The failure message, or when the call did not fail. + public sealed record ToolCalled( + HookScope Scope, + string ToolName, + string CallId, + TimeSpan Duration, + ToolOutcome Outcome, + bool Fatal, + ToolFailureKind? FailureKind, + string? Failure) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs b/src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs new file mode 100644 index 00000000..577c8a7a --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs @@ -0,0 +1,27 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// How one tool call ended. + public enum ToolOutcome + { + /// The tool returned a result. + Ok, + + /// The tool faulted. + Failed, + + /// A hook blocked the call. + Blocked, + + /// A hook answered in place of the tool. + Responded, + + /// The result came from the tool's cache. + Cached, + + /// The tool ran past its time limit. + TimedOut, + + /// The model called a tool the entry does not declare. + Undeclared, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs b/src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs new file mode 100644 index 00000000..0aacd6b6 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs @@ -0,0 +1,26 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The turn was sealed. + /// Where and when it happened. + /// How the turn ended. + /// The caller's words. The text is the caller's conversation text: a hook that logs it logs personal data. + /// The words the turn's reply rows hold. For a cut turn, the whole text the model produced; carries what was heard. It is the caller's conversation text: a hook that logs it logs personal data. + /// The stage when the turn began. + /// The stage when the turn ended. + /// How long the turn took. + /// The reason for , and , or null. + /// Whether the fallback came from a tool that spent its retry budget. + /// The exception behind the failure, for a log's stack trace, or null. + public sealed record TurnCompleted( + HookScope Scope, + TurnOutcome Outcome, + string UserText, + string ReplyText, + string StageBefore, + string StageAfter, + TimeSpan Duration, + string? Failure, + bool FailedInTool, + Exception? Cause) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnLatency.cs b/src/AgentCore.Application/Hooks/Notices/TurnLatency.cs new file mode 100644 index 00000000..c32fe4fd --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnLatency.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A voice turn's timing is known. + /// Where and when it happened. + /// Which reading it is. + /// The measured time. + public sealed record TurnLatency(HookScope Scope, LatencyMetric Metric, TimeSpan Value) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs b/src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs new file mode 100644 index 00000000..8505906e --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs @@ -0,0 +1,24 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// How a turn ended. + public enum TurnOutcome + { + /// The model answered. + Answered, + + /// The turn spoke the fallback reply. + Fallback, + + /// The model produced no reply. + Empty, + + /// A hook blocked the turn. + Blocked, + + /// The turn was cancelled. + Cancelled, + + /// The turn faulted. + Faulted, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs b/src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs new file mode 100644 index 00000000..9fae9c38 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs @@ -0,0 +1,27 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Why a turn was refused and kept none of its words. + public enum TurnRefusal + { + /// Another turn held the conversation past the wait limit. + Busy, + + /// The store refused the turn's words: another session saved that turn first. + Conflict, + + /// The client left while the turn waited for the conversation. + Gone, + + /// The caller let go of the turn before it read the reply, so the model never ran. + Dropped, + + /// The conversation had reached a terminal stage. + Terminal, + + /// The session was disposed. + Disposed, + + /// Another entry holds the conversation. + InUse, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs b/src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs new file mode 100644 index 00000000..d9933aeb --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs @@ -0,0 +1,25 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The audit token of each , under refusedReason. + public static class TurnRefusalTokens + { + /// Names one refusal reason. + /// The reason. + /// The token the audit row and the log line carry. + /// is outside the closed set. + public static string ToToken(TurnRefusal refusal) + { + return refusal switch + { + TurnRefusal.Busy => "busy", + TurnRefusal.Conflict => "conflict", + TurnRefusal.Gone => "gone", + TurnRefusal.Dropped => "dropped", + TurnRefusal.Terminal => "terminal", + TurnRefusal.Disposed => "disposed", + TurnRefusal.InUse => "in_use", + _ => throw new ArgumentOutOfRangeException(nameof(refusal), refusal, "The refusal is outside the closed set."), + }; + } + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnRefused.cs b/src/AgentCore.Application/Hooks/Notices/TurnRefused.cs new file mode 100644 index 00000000..cf12f7a9 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnRefused.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A turn was refused or dropped, so none of its words were kept. + /// Where and when it happened. is null when the turn was + /// refused before it read the conversation. + /// Why. names it for an audit row. + /// + /// Whether the conversation had already ended. Such a refusal still reaches the hooks, but no audit row follows + /// conversation.ended. + /// + public sealed record TurnRefused(HookScope Scope, TurnRefusal Reason, bool AfterEnd) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnStarted.cs b/src/AgentCore.Application/Hooks/Notices/TurnStarted.cs new file mode 100644 index 00000000..5e3a2fa9 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnStarted.cs @@ -0,0 +1,14 @@ +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.Application.Hooks.Notices +{ + /// The turn was admitted and its agent chosen. + /// Where and when it happened. + /// The agent that runs the turn. + /// + /// The caller's words as received, before any hook ran: a + /// does not change a already raised, so a hook that + /// redacts input does not redact this. The text is the caller's conversation text: a hook that logs it logs personal data. + /// + public sealed record TurnStarted(HookScope Scope, string AgentId, string UserText) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs b/src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs new file mode 100644 index 00000000..b5075ca8 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Edit-and-resend withdrew turns. + /// Where and when it happened. + /// The first turn index withdrawn. + /// The last turn index withdrawn. + public sealed record TurnSuperseded(HookScope Scope, int WithdrewFrom, int WithdrewThrough) : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Hooks/Notices/UnloadCause.cs b/src/AgentCore.Application/Hooks/Notices/UnloadCause.cs new file mode 100644 index 00000000..484c4fa4 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/UnloadCause.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Why a session left memory. + public enum UnloadCause + { + /// The session sat idle past its limit. + Idle, + + /// The session was closed. + Closed, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/VoiceParty.cs b/src/AgentCore.Application/Hooks/Notices/VoiceParty.cs new file mode 100644 index 00000000..ee65314f --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/VoiceParty.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// Whose voice state changed. + public enum VoiceParty + { + /// The caller. + User, + + /// The agent. + Agent, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/VoiceState.cs b/src/AgentCore.Application/Hooks/Notices/VoiceState.cs new file mode 100644 index 00000000..d0823e93 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/VoiceState.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// A voice state. + public enum VoiceState + { + /// Waiting for speech. + Listening, + + /// Working out a reply. + Thinking, + + /// Speaking. + Speaking, + + /// Not on the call. + Away, + } +} diff --git a/src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs b/src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs new file mode 100644 index 00000000..5611a3d9 --- /dev/null +++ b/src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs @@ -0,0 +1,14 @@ +namespace AgentCore.Application.Hooks.Notices +{ + /// The user's or the agent's voice state changed. + /// Where and when it happened. + /// Whose state changed. + /// The state before. + /// The state after. + public sealed record VoiceStateChanged( + HookScope Scope, + VoiceParty Party, + VoiceState Old, + VoiceState New) + : HookNotice(Scope); +} diff --git a/src/AgentCore.Application/Knowledge/ClaimedProbe.cs b/src/AgentCore.Application/Knowledge/ClaimedProbe.cs index 3022306b..e36711ca 100644 --- a/src/AgentCore.Application/Knowledge/ClaimedProbe.cs +++ b/src/AgentCore.Application/Knowledge/ClaimedProbe.cs @@ -1,4 +1,4 @@ -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Clarification; namespace AgentCore.Application.Knowledge { diff --git a/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs b/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs index 0fc9d7f7..0350ccb3 100644 --- a/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs +++ b/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs @@ -1,10 +1,10 @@ using System.Text.Json; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using Microsoft.Extensions.AI; using Microsoft.Agents.AI; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Knowledge { diff --git a/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs b/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs index ecac7d81..f3452d54 100644 --- a/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs +++ b/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs @@ -1,7 +1,6 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; using AgentCore.Application.Runtime.Turn; -using AgentCore.Application.Runtime; namespace AgentCore.Application.Knowledge { diff --git a/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs b/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs index 13543e6b..49a71795 100644 --- a/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs +++ b/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs @@ -1,15 +1,15 @@ using System.Diagnostics; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Clarification; namespace AgentCore.Application.Knowledge { /// - /// §8 steps 3-6: the probe. When a scoped search clears no card, it drops one wildcard-filled facet, + /// The probe. When a scoped search clears no card, it drops one wildcard-filled facet, /// searches again, and names what the wider search holds. /// internal static class KnowledgeProbe @@ -30,7 +30,7 @@ internal static class KnowledgeProbe bool carriesHistory, CancellationToken cancellationToken) { - // K42: a nested tool call runs with the holder stripped from its invocation, so a delegated + // A nested tool call runs with the holder stripped from its invocation, so a delegated // run's own search cannot latch, count or record — but a scoped run still owes the caller // the notice. if (clarifications is null || ProbeWiring.From(binding.Knowledge.Clarification) is not { } wiring) @@ -38,7 +38,7 @@ internal static class KnowledgeProbe return [KnowledgeNotices.Of(KnowledgeNotices.Empty)]; } - // §8 step 3. Recomputed fresh on every call in the turn — including one that arrives after the + // Recomputed fresh on every call in the turn — including one that arrives after the // turn's probe has already run — so it must stay cheap, deterministic and repeatable rather // than mutate anything. Nothing is latched by this exit: a second call that also finds no // droppable facet simply reaches this same conclusion again. @@ -61,7 +61,7 @@ internal static class KnowledgeProbe // that a throw already took away. try { - // §8 step 4: the latch and the increment both belong before the search runs. An increment + // The latch and the increment both belong before the search runs. An increment // placed after it would let a probe that always times out offer the same facet forever; a // latch placed after it would leave a throwing first call's latch unset, so a second call // in the same turn would re-run the search and advance probeAsks a second time. @@ -104,7 +104,7 @@ internal static class KnowledgeProbe } } - /// §8 step 4: the probe's own second search, under the narrowed scope, logged like the main one. + /// The probe's own second search, under the narrowed scope, logged like the main one. private static async Task> SearchAsync( KnowledgeBinding binding, KnowledgeScope narrowedScope, @@ -132,7 +132,7 @@ private static async Task> SearchAsync( return probeCards; } - /// K43: replays the outcome of the one probe this turn already claimed. + /// Replays the outcome of the one probe this turn already claimed. private static async Task> ReplayAsync( Clarifications.Probe probe, KnowledgeAmbiguityConfiguration ambiguity, @@ -146,14 +146,14 @@ private static async Task> SearchAsync( } catch (Exception failure) when (!KnowledgeCancellation.ByCaller(failure, cancellationToken)) { - // The winner's own search already answered for reachability (§8 step 4's own catch says - // the same thing): a wait that timed out, or a payload the winner failed via Probe.Fail(), + // The winner's own search already answered for reachability (the catch around the probe's own + // search says the same thing): a wait that timed out, or a payload the winner failed via Probe.Fail(), // is never grounds to tell this caller the store is unreachable. return [KnowledgeNotices.Of(KnowledgeNotices.Empty)]; } } - /// §8 steps 5-6: reads the candidates out of the probe's cards and decides what to say. + /// Reads the candidates out of the probe's cards and decides what to say. private static IReadOnlyList Name( KnowledgeBinding binding, ProbeWiring wiring, @@ -163,7 +163,7 @@ private static async Task> SearchAsync( { (Clarifications? clarifications, Clarifications.Probe? probe, string? facet) = claimed; - // §8 step 5: the value at the facet's payload path is a string or a list of strings alike — + // The value at the facet's payload path is a string or a list of strings alike — // the real corpus stores arrays, and a cast straight to string would silently return null for // every card on a multi-model collection. string path = wiring.Template.Resolve(facet); @@ -180,14 +180,14 @@ private static async Task> SearchAsync( return Publish(probe, KnowledgeNotices.Empty); } - // §8 step 6. probeAsks was already advanced in step 4; nothing below chooses anything but the + // probeAsks was already advanced before the second search; nothing below chooses anything but the // message. Clarifications.LastNamed wouldName = Clarifications.LastNamed.For(union, wiring.Ambiguity.MaxCandidates); List candidates = [.. union]; - // K39, drawn for the record rather than for the message: on a graph row AgentCore cannot know + // Drawn for the record rather than for the message: on a graph row AgentCore cannot know // whether the participant's own tool result ever reached the caller, so the note still goes - // out, but the record of what was named — which arms K21's tie-break — does not. The flag + // out, but the record of what was named — which arms the tie-break — does not. The flag // arrives as a parameter because the probe runs inside a search delegate that is never // handed a session to compare. @@ -226,12 +226,12 @@ private static async Task> SearchAsync( } /// - /// §8 step 3: the first facet, in fromState declaration order, the wildcard filled and that + /// The first facet, in fromState declaration order, the wildcard filled and that /// dropping would not empty the scope or skip a slot at its ask cap. /// private static string? DroppableFacet(ProbeWiring wiring, KnowledgeScope scope, Clarifications clarifications) { - // K33: the scope's only facet is undroppable — opening it empty is what a scoped store + // The scope's only facet is undroppable — opening it empty is what a scoped store // refuses. This holds for every candidate alike, so no candidate can be droppable at all. if (scope.Facets.Count <= 1) { @@ -240,7 +240,7 @@ private static async Task> SearchAsync( foreach (string name in wiring.FromState) { - // K14: the wildcard filled it — its value is the wildcard's own, and the facet is named + // The wildcard filled it — its value is the wildcard's own, and the facet is named // among the ones the wildcard is allowed to widen. Origins overrules the value where it // has an entry: a host that pinned a facet to the wildcard literal meant "every value of // it", and widening that facet would overrule an instruction rather than recover a lost @@ -255,7 +255,7 @@ private static async Task> SearchAsync( continue; } - // K22: the probe's own counter is monotone and capped at maxAsks. + // The probe's own counter is monotone and capped at maxAsks. if (clarifications.Read(name).ProbeAsks >= wiring.Ambiguity.MaxAsks) { continue; @@ -267,17 +267,10 @@ private static async Task> SearchAsync( return null; } - /// Opens the same scope with one facet removed, for §8 step 4's second search. + /// Opens the same scope with one facet removed, for the probe's second search. /// The scope the main search ran under. /// The facet to drop. /// The narrowed scope. - /// - /// Each map keeps its own comparer. Rebuilding on a fixed ordinal comparer would narrow how a - /// case-insensitive caller's facets match, so the second search would run under different matching - /// rules than the first. Origins loses the facet alongside Facets, because it describes what the - /// query actually filters on — a retrieval record built from a scope that kept it would name a - /// facet the probe's search never constrained. - /// private static KnowledgeScope WithoutFacet(KnowledgeScope scope, string facet) { Dictionary facets = new(scope.Facets, ComparerOf(scope.Facets)); @@ -296,7 +289,7 @@ private static IEqualityComparer ComparerOf(IReadOnlyDictionary - /// §8 step 5: walks a dotted path into one card's Extras, reading a string or a list of + /// Walks a dotted path into one card's Extras, reading a string or a list of /// strings alike — the shape QdrantPointConverter gives a Qdrant scalar or list value. /// private static IEnumerable FacetValues(KnowledgeCard card, string path) diff --git a/src/AgentCore.Application/Knowledge/KnowledgeSearch.cs b/src/AgentCore.Application/Knowledge/KnowledgeSearch.cs index 99ed719d..7a5f5654 100644 --- a/src/AgentCore.Application/Knowledge/KnowledgeSearch.cs +++ b/src/AgentCore.Application/Knowledge/KnowledgeSearch.cs @@ -3,8 +3,8 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; @@ -49,9 +49,8 @@ internal static Core Bind(KnowledgeBinding binding) // No scope filters nothing, so it is the absent scope in disguise. The shared store // can only fail closed when EVERY agent is scoped, so in a mixed deployment this is // the only check standing between a scoped agent and every customer's cards. A null - // turn — a framework-driven sub-run (graph node, background child) the loop never - // invoked — searches the whole corpus unscoped and names NoScope scoped: loud, never - // a leak. + // turn — a run no conversation turn reached — searches the whole corpus unscoped and + // names NoScope scoped: loud, never a leak. KnowledgeScope? composed = knowledge.Scoped ? turn?.Knowledge : WholeCorpus; if (knowledge.Scoped && composed is not { Facets.Count: > 0 }) @@ -68,6 +67,8 @@ internal static Core Bind(KnowledgeBinding binding) double? searched = null; KnowledgeScope under = narrowed; + // One KnowledgeSearched per search, raised once its outcome is known: a search the store answered can + // still fail on the agent's limit, the citations or the probe. try { (IReadOnlyList? cards, double latency) = await Under(narrowed).ConfigureAwait(false); @@ -76,22 +77,28 @@ internal static Core Bind(KnowledgeBinding binding) if (cards.Count == 0 && byTool) { under = scope; - (cards, _) = await Under(scope).ConfigureAwait(false); + (cards, latency) = await Under(scope).ConfigureAwait(false); + searched = latency; } + IReadOnlyList results; if (cards.Count == 0 && knowledge.Mode == KnowledgeMode.Tool && under.Facets.Count > 0) { - return await KnowledgeProbe + results = await KnowledgeProbe .RunAsync(binding, under, query, turn?.Clarifications, turn?.CarriesHistory ?? false, cancellationToken) .ConfigureAwait(false); } + else + { + List shown = Kept(cards, knowledge); + Cite(shown, knowledge, citations, turn?.Sources, turn?.OuterCallId); + results = Map(shown, knowledge, citations); + } - List shown = Kept(cards, knowledge); - Cite(shown, knowledge, citations, turn?.Sources); - - return Map(shown, knowledge, citations); + RaiseSearched(turn, query, under, cards.Count, latency, failure: null); + return results; } catch (Exception failure) when (!KnowledgeCancellation.ByCaller(failure, cancellationToken)) { @@ -102,6 +109,8 @@ internal static Core Bind(KnowledgeBinding binding) Log.KnowledgeRetrievalFailed(logger, agent, record, failure); + RaiseSearched(turn, query, under, hits: 0, latency, $"{failure.GetType().Name}: {failure.Message}"); + return [KnowledgeNotices.Of(KnowledgeNotices.Unreachable)]; } @@ -124,6 +133,15 @@ internal static Core Bind(KnowledgeBinding binding) } } + private static void RaiseSearched(TurnInvocation? turn, string query, KnowledgeScope under, int hits, double latency, string? failure) + { + if (turn?.Hooks is { } hooks) + { + _ = hooks.Raise(new KnowledgeSearched( + hooks.Scope(turn.TurnIndex, turn.Stage), query, under, hits, TimeSpan.FromMilliseconds(latency), failure)); + } + } + /// Cuts one search down to the agent's limit:. /// What the store returned, best first, links last. /// The agent's resolved knowledge: block. @@ -151,11 +169,13 @@ private static List Kept(IReadOnlyList cards, Reso /// The agent's resolved knowledge: block. /// The wording providers.knowledge.citation named. /// What the turn cites into, or outside a turn. + /// The outermost tool call this search runs inside, or outside one. private static void Cite( IReadOnlyList cards, ResolvedKnowledge knowledge, IKnowledgeCitationFormatter citations, - TurnSources? port) + TurnSources? port, + string? callId) { if (!knowledge.Citations || port is null) { @@ -166,7 +186,7 @@ private static void Cite( { if (KnowledgeSourceMapper.ToSource(card, citations) is { } source) { - port.Publish(source); + port.Publish(source, callId); } } } diff --git a/src/AgentCore.Application/Llm/ConversationRequestStamp.cs b/src/AgentCore.Application/Llm/ConversationRequestStamp.cs index 0f691f08..c6a45417 100644 --- a/src/AgentCore.Application/Llm/ConversationRequestStamp.cs +++ b/src/AgentCore.Application/Llm/ConversationRequestStamp.cs @@ -1,5 +1,5 @@ -using AgentCore.Application.Runtime; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Llm { diff --git a/src/AgentCore.Application/Ports/IConversationObserver.cs b/src/AgentCore.Application/Ports/IConversationObserver.cs deleted file mode 100644 index 281ca304..00000000 --- a/src/AgentCore.Application/Ports/IConversationObserver.cs +++ /dev/null @@ -1,48 +0,0 @@ -using AgentCore.Application.Runtime; - -namespace AgentCore.Application.Ports -{ - /// - /// Reads the facts of a conversation as the turn loop raises them. It never sits on the turn. - /// - /// - /// - /// This is the one seam between the turn loop and everything that watches it. The audit chain of D23, - /// the counters of section 8.6, and the "log once" rows of section 8.7 are three implementations of - /// this port, and knows none of them: it raises a - /// and moves on. - /// - /// - /// The contract of applies here in full, and for the same measurement. - /// Section 7 puts a durable insert at 13 ms at p50 and 32 ms at p99, against 91 nanoseconds to - /// enqueue, so an observer completes when it has ACCEPTED the fact and never when it has stored it. - /// An implementation that awaits a database, an HTTP call, or a disk inside - /// has broken the contract, and the turn loop pays for it. - /// - /// - /// The dispatcher behind this port never lets an observer end a turn: a fault is logged once and - /// swallowed, and an observer that does not complete at once is observed off-turn. An implementation - /// may therefore throw, and nothing that throws is retried. Observers of one conversation are also invoked in - /// the order the conversation raised the facts, so a late fact never overtakes an earlier one. - /// - /// - /// One instance serves every conversation, because a names its own conversation. Nothing here - /// is per conversation, and an implementation that holds per-call state must key it by - /// . - /// - /// - public interface IConversationObserver - { - /// Takes one fact about a conversation. - /// What happened. Nothing edits it afterwards. - /// - /// Cancels the acceptance, and never the work behind it. The dispatcher passes - /// : the record belongs to the conversation, and not to a turn the - /// caller may have cancelled. - /// - /// - /// A task that completes when the observer has accepted the fact. It is not stored yet. - /// - ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken); - } -} diff --git a/src/AgentCore.Application/Ports/IConversationPort.cs b/src/AgentCore.Application/Ports/IConversationPort.cs index 1e974d72..5a027089 100644 --- a/src/AgentCore.Application/Ports/IConversationPort.cs +++ b/src/AgentCore.Application/Ports/IConversationPort.cs @@ -1,7 +1,9 @@ -using AgentCore.Application.Runtime; using AgentCore.Domain; using AgentCore.Domain.Knowledge; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Ports { @@ -20,7 +22,6 @@ public interface IConversationPort bool IsComplete { get; } /// Gets or sets the knowledge scope the host opened for this conversation, or null for none. - /// Set it before the run: every turn composes its own scope from it. It outlives turns by construction — one conversation hears one customer. KnowledgeScope? Scope { get; set; } /// Gets the turn that finished last, or before the first turn ends. @@ -48,11 +49,6 @@ public interface IConversationPort /// What the caller said. /// Cancels the model calls. /// The reply, one update at a time. Every update carries content. - /// - /// The turn finishes when the enumeration finishes, and holds it after - /// that. The stream carries no lifecycle update: section 8.6 measured seven of those in one - /// 40-fragment reply, and an adapter must not have to filter them again. - /// /// /// The conversation already ended, or another turn of this conversation still ran after the wait limit. /// @@ -86,7 +82,7 @@ IAsyncEnumerable RunTurnMessageStreamingAsync( 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). + /// Cuts one turn's reply where the user stopped seeing or hearing it. /// The turn the user was seeing or hearing. /// /// What reached the user. keeps everything the turn yielded. diff --git a/src/AgentCore.Application/Ports/IConversationSessionFactory.cs b/src/AgentCore.Application/Ports/IConversationSessionFactory.cs index 6abcbb01..44232914 100644 --- a/src/AgentCore.Application/Ports/IConversationSessionFactory.cs +++ b/src/AgentCore.Application/Ports/IConversationSessionFactory.cs @@ -1,5 +1,5 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Ports { diff --git a/src/AgentCore.Application/Ports/IConversationSessions.cs b/src/AgentCore.Application/Ports/IConversationSessions.cs index adc2032e..4fb4b1ae 100644 --- a/src/AgentCore.Application/Ports/IConversationSessions.cs +++ b/src/AgentCore.Application/Ports/IConversationSessions.cs @@ -1,5 +1,5 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Ports { @@ -46,7 +46,7 @@ ValueTask GetOrOpenAsync( ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default); /// - /// Closes one conversation's session: waits for the words it still owes store 1, disposes its background + /// Closes one conversation's session: waits for the words it still owes the message store, 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 diff --git a/src/AgentCore.Application/Ports/IConversationStore.cs b/src/AgentCore.Application/Ports/IConversationStore.cs index d9373409..9c73653e 100644 --- a/src/AgentCore.Application/Ports/IConversationStore.cs +++ b/src/AgentCore.Application/Ports/IConversationStore.cs @@ -5,7 +5,7 @@ namespace AgentCore.Application.Ports { - /// Where store 0 keeps what a conversation is, apart from its words. + /// Where the conversation store keeps what a conversation is, apart from its words. public interface IConversationStore { /// Makes the conversation's row, or returns the one already there. @@ -17,7 +17,7 @@ public interface IConversationStore /// Reads one conversation's row. /// The conversation to read. /// Cancels the read. - /// The row, or when store 0 holds none. + /// The row, or when the conversation store holds none. ValueTask GetAsync(string conversationId, CancellationToken cancellationToken = default); /// Lists one principal's conversations, most recently active first. @@ -92,6 +92,20 @@ ValueTask> AppendAsync( ConversationSessionState? state = null, CancellationToken cancellationToken = default); + /// + /// Writes the state the session holds when no turn's words carry it, as when a host ends the conversation after + /// its last turn. It follows the rule of : a state whose + /// is behind the stored state's is dropped, so it never + /// replaces a newer one, and one level with it replaces it. + /// + /// The conversation to write. A conversation the conversation store holds no row for is left alone. + /// What the session holds now. + /// Cancels the write. + ValueTask SaveStateAsync( + string conversationId, + ConversationSessionState state, + CancellationToken cancellationToken = default); + /// /// Appends one message to a conversation from outside any turn. The store numbers the row and stamps it /// with the turn the conversation takes next. Works whether or not a session for the conversation is live. diff --git a/src/AgentCore.Application/Ports/IConversationStoreAdapter.cs b/src/AgentCore.Application/Ports/IConversationStoreAdapter.cs index df1007dc..90847903 100644 --- a/src/AgentCore.Application/Ports/IConversationStoreAdapter.cs +++ b/src/AgentCore.Application/Ports/IConversationStoreAdapter.cs @@ -3,7 +3,7 @@ namespace AgentCore.Application.Ports { /// - /// Opens the store 0 backing behind one providers.conversations value. + /// Opens the conversation store behind one providers.conversations value. /// public interface IConversationStoreAdapter : IVendorAdapter { diff --git a/src/AgentCore.Application/Ports/IConversationTitler.cs b/src/AgentCore.Application/Ports/IConversationTitler.cs index 004eb73d..2bbb8358 100644 --- a/src/AgentCore.Application/Ports/IConversationTitler.cs +++ b/src/AgentCore.Application/Ports/IConversationTitler.cs @@ -11,7 +11,7 @@ public interface IConversationTitler /// The title in pieces, in order. IAsyncEnumerable GenerateAsync(string conversationId, CancellationToken cancellationToken = default); - // A turn reaches store 1 only once it has finished, so a browser naming a conversation the + // A turn reaches the message store only once it has finished, so a browser naming a conversation the // moment it starts finds nothing there to read. It hands over what it is showing instead. /// Generates one conversation's title from messages the caller holds, streaming it as it arrives. /// The conversation to title. Its stored messages are not read. diff --git a/src/AgentCore.Application/Ports/ISourcePort.cs b/src/AgentCore.Application/Ports/ISourcePort.cs deleted file mode 100644 index e313e37b..00000000 --- a/src/AgentCore.Application/Ports/ISourcePort.cs +++ /dev/null @@ -1,14 +0,0 @@ -using AgentCore.Domain.Sources; - -namespace AgentCore.Application.Ports -{ - /// - /// Carries where an answer came from, from whatever produced it to the host that shows it. - /// - public interface ISourcePort - { - /// Cites one source for the running conversation. - /// Where the answer came from. - void Publish(SourceReference source); - } -} diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index 0321c888..765c2947 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -93,6 +93,7 @@ AgentCore.Application.Conversation.ChatConversationTitler.GenerateAsync(string! AgentCore.Application.Conversation.ChatConversationTitler.GenerateFromAsync(string! conversationId, System.Collections.Generic.IReadOnlyList! messages, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! AgentCore.Application.Conversation.Memory.InMemoryConversationStore AgentCore.Application.Conversation.Memory.InMemoryConversationStore.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.Conversation.Memory.InMemoryConversationStore.SaveStateAsync(string! conversationId, AgentCore.Application.Conversation.ConversationSessionState! state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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 @@ -381,6 +382,8 @@ AgentCore.Application.Configuration.Schema.ApprovalConfiguration.Auto.get -> Sys AgentCore.Application.Configuration.Schema.ApprovalConfiguration.Auto.init -> void AgentCore.Application.Configuration.Schema.ApprovalConfiguration.Equals(AgentCore.Application.Configuration.Schema.ApprovalConfiguration? other) -> bool AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.AnswerSeconds.get -> int? +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.AnswerSeconds.init -> void AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.$() -> AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.ConversationProviderConfiguration() -> void AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.CloseTimeoutSeconds.get -> int? @@ -390,6 +393,8 @@ AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Idl AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.IdleTimeoutSeconds.init -> void AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Kind.get -> string! AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Kind.init -> void +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Live.get -> System.Text.Json.JsonElement +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Live.init -> void AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.MaxFrameBytes.get -> int? AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.MaxFrameBytes.init -> void AgentCore.Application.Configuration.Schema.EmbeddingProviderConfiguration @@ -729,8 +734,6 @@ AgentCore.Application.Configuration.Schema.ProvidersConfiguration.Speech.get -> AgentCore.Application.Configuration.Schema.ProvidersConfiguration.Speech.init -> void AgentCore.Application.Configuration.Schema.ProvidersConfiguration.Telemetry.get -> AgentCore.Application.Configuration.Schema.TelemetryProviderConfiguration? AgentCore.Application.Configuration.Schema.ProvidersConfiguration.Telemetry.init -> void -AgentCore.Application.Configuration.Schema.ProvidersConfiguration.Telephony.get -> AgentCore.Application.Configuration.Schema.VendorProviderConfiguration? -AgentCore.Application.Configuration.Schema.ProvidersConfiguration.Telephony.init -> void AgentCore.Application.Configuration.Schema.ReservedStateSlots AgentCore.Application.Configuration.Schema.ShellConfiguration AgentCore.Application.Configuration.Schema.ShellConfiguration.$() -> AgentCore.Application.Configuration.Schema.ShellConfiguration! @@ -1007,19 +1010,18 @@ 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.IConversationObserver -AgentCore.Application.Ports.IConversationObserver.OnConversationEventAsync(AgentCore.Application.Runtime.ConversationEvent! conversationEvent, System.Threading.CancellationToken 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.IConversationSessionFactory.Create(string? conversationId = null, AgentCore.Application.Conversation.ConversationSessionState? state = null) -> AgentCore.Application.Runtime.Session.ConversationSession! AgentCore.Application.Ports.IConversationSessionRegistry AgentCore.Application.Ports.IConversationSessionRegistry.Entries.get -> System.Collections.Generic.IReadOnlyCollection! AgentCore.Application.Ports.IConversationSessionRegistry.Sessions.get -> AgentCore.Application.Ports.IConversationSessions! AgentCore.Application.Ports.IConversationSessions 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.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.SaveStateAsync(string! conversationId, AgentCore.Application.Conversation.ConversationSessionState! state, 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 @@ -1061,8 +1063,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.IConversationPort.Cut(int turnIndex, AgentCore.Application.Runtime.Cut.TurnCut! cut) -> bool +AgentCore.Application.Ports.IConversationPort.Recut(int turnIndex, AgentCore.Application.Runtime.Cut.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 @@ -1080,8 +1082,6 @@ AgentCore.Application.Ports.IModerationAdapter AgentCore.Application.Ports.IModerationAdapter.CreateAsync(AgentCore.Application.Configuration.Schema.VendorProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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.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 @@ -1109,93 +1109,60 @@ AgentCore.Application.Runtime.AgentCoreAgent 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! -AgentCore.Application.Runtime.ConversationEvent.AmendsEventId.get -> System.Guid? -AgentCore.Application.Runtime.ConversationEvent.AmendsEventId.init -> void -AgentCore.Application.Runtime.ConversationEvent.ConversationEvent() -> void -AgentCore.Application.Runtime.ConversationEvent.ConversationId.get -> string! -AgentCore.Application.Runtime.ConversationEvent.ConversationId.init -> void -AgentCore.Application.Runtime.ConversationEvent.Equals(AgentCore.Application.Runtime.ConversationEvent? other) -> bool -AgentCore.Application.Runtime.ConversationEvent.EventId.get -> System.Guid? -AgentCore.Application.Runtime.ConversationEvent.EventId.init -> void -AgentCore.Application.Runtime.ConversationEvent.Kind.get -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEvent.Kind.init -> void -AgentCore.Application.Runtime.ConversationEvent.OccurredAt.get -> System.DateTimeOffset -AgentCore.Application.Runtime.ConversationEvent.OccurredAt.init -> void -AgentCore.Application.Runtime.ConversationEvent.Payload.get -> System.Collections.Generic.IReadOnlyDictionary! -AgentCore.Application.Runtime.ConversationEvent.Payload.init -> void -AgentCore.Application.Runtime.ConversationEvent.TurnIndex.get -> int? -AgentCore.Application.Runtime.ConversationEvent.TurnIndex.init -> void -AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ConversationEnded = 9 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ConversationStarted = 0 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.EmptyReply = 5 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ExtractionFailed = 6 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ModerationClean = 3 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ModerationUnavailable = 2 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.PromptFlagged = 1 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ReplyInterrupted = 8 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.StateRestorePartial = 11 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.ToolFailed = 4 -> AgentCore.Application.Runtime.ConversationEventKind -AgentCore.Application.Runtime.ConversationEventKind.TranscriptResyncFailed = 13 -> AgentCore.Application.Runtime.ConversationEventKind -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! -AgentCore.Application.Runtime.ConversationSession.Compiled.get -> AgentCore.Application.Configuration.Compilation.CompiledAgent! -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.IsComplete.get -> bool -AgentCore.Application.Runtime.ConversationSession.LastReplyMessageId.get -> string? -AgentCore.Application.Runtime.ConversationSession.LastTurn.get -> AgentCore.Domain.TurnResult? -AgentCore.Application.Runtime.ConversationSession.RunTurnAsync(string! userInput, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.ConversationSession.RunTurnAtOriginAsync(string! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.ConversationSession.RunTurnMessageAsync(Microsoft.Extensions.AI.ChatMessage! userInput, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.ConversationSession.RunTurnMessageAtOriginAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.ConversationSession.RunTurnMessageStreamingAsync(Microsoft.Extensions.AI.ChatMessage! userInput, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! -AgentCore.Application.Runtime.ConversationSession.RunTurnMessageStreamingAtOriginAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! -AgentCore.Application.Runtime.ConversationSession.RunTurnStreamingAsync(string! userInput, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! -AgentCore.Application.Runtime.ConversationSession.RunTurnStreamingAtOriginAsync(string! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! -AgentCore.Application.Runtime.ConversationSession.Scope.get -> AgentCore.Domain.Knowledge.KnowledgeScope? -AgentCore.Application.Runtime.ConversationSession.Scope.set -> void -AgentCore.Application.Runtime.ConversationSession.SetDelegatedTools(string! delegatingToolId, System.Collections.Generic.IReadOnlyList! tools) -> void -AgentCore.Application.Runtime.ConversationSession.Stage.get -> string! -AgentCore.Application.Runtime.ConversationSession.State.get -> AgentCore.Application.State.StateDocument! -AgentCore.Application.Runtime.ConversationSession.Transcript.get -> System.Collections.Generic.IReadOnlyList! -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! -AgentCore.Application.Runtime.ConversationTurnOrigin -AgentCore.Application.Runtime.ConversationTurnOrigin.$() -> AgentCore.Application.Runtime.ConversationTurnOrigin! -AgentCore.Application.Runtime.ConversationTurnOrigin.ConversationTurnOrigin(string? MessageId, string? ParentMessageId) -> void -AgentCore.Application.Runtime.ConversationTurnOrigin.Deconstruct(out string? MessageId, out string? ParentMessageId) -> void -AgentCore.Application.Runtime.ConversationTurnOrigin.Equals(AgentCore.Application.Runtime.ConversationTurnOrigin? other) -> bool -AgentCore.Application.Runtime.ConversationTurnOrigin.MessageId.get -> string? -AgentCore.Application.Runtime.ConversationTurnOrigin.MessageId.init -> void -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.Runtime.Session.ConversationSession +AgentCore.Application.Runtime.Session.ConversationSession.ConversationId.get -> string! +AgentCore.Application.Runtime.Session.ConversationSession.Compiled.get -> AgentCore.Application.Configuration.Compilation.CompiledAgent! +AgentCore.Application.Runtime.Session.ConversationSession.DisposeAsync() -> System.Threading.Tasks.ValueTask +AgentCore.Application.Runtime.Session.ConversationSession.EndConversation(AgentCore.Domain.Audit.ConversationEndReason reason) -> bool +AgentCore.Application.Runtime.Session.ConversationSession.FlushTranscriptAsync() -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.FlushNoticesAsync() -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.IsComplete.get -> bool +AgentCore.Application.Runtime.Session.ConversationSession.LastReplyMessageId.get -> string? +AgentCore.Application.Runtime.Session.ConversationSession.LastTurn.get -> AgentCore.Domain.TurnResult? +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnAsync(string! userInput, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnAtOriginAsync(string! userInput, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnMessageAsync(Microsoft.Extensions.AI.ChatMessage! userInput, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnMessageAtOriginAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnMessageStreamingAsync(Microsoft.Extensions.AI.ChatMessage! userInput, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnMessageStreamingAtOriginAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnStreamingAsync(string! userInput, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.Application.Runtime.Session.ConversationSession.RunTurnStreamingAtOriginAsync(string! userInput, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.Application.Runtime.Session.ConversationSession.Scope.get -> AgentCore.Domain.Knowledge.KnowledgeScope? +AgentCore.Application.Runtime.Session.ConversationSession.Scope.set -> void +AgentCore.Application.Runtime.Session.ConversationSession.SetDelegatedTools(string! delegatingToolId, System.Collections.Generic.IReadOnlyList! tools) -> void +AgentCore.Application.Runtime.Session.ConversationSession.Stage.get -> string! +AgentCore.Application.Runtime.Session.ConversationSession.State.get -> AgentCore.Application.State.StateDocument! +AgentCore.Application.Runtime.Session.ConversationSession.Transcript.get -> System.Collections.Generic.IReadOnlyList! +AgentCore.Application.Runtime.Session.ConversationSession.TryCreateApprovalAnswer(string! requestId, bool approved) -> Microsoft.Extensions.AI.ChatMessage? +AgentCore.Application.Runtime.Session.ConversationSession.TryCreateApprovalAnswerAsync(string! requestId, bool approved, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Runtime.Session.ConversationSession.Workspace.get -> string? +AgentCore.Application.Runtime.Session.ConversationSession.Cut(int turnIndex, AgentCore.Application.Runtime.Cut.TurnCut! cut) -> bool +AgentCore.Application.Runtime.Session.ConversationSession.Recut(int turnIndex, AgentCore.Application.Runtime.Cut.TurnCut! cut) -> bool +AgentCore.Application.Runtime.Session.ConversationSessionFactory +AgentCore.Application.Runtime.Session.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? hooks = null, string? workspaceRoot = null) -> void +AgentCore.Application.Runtime.Session.ConversationSessionFactory.Create(string? conversationId = null, AgentCore.Application.Conversation.ConversationSessionState? state = null) -> AgentCore.Application.Runtime.Session.ConversationSession! +AgentCore.Application.Runtime.Session.ConversationSessionFactory.DisposeAsync() -> System.Threading.Tasks.ValueTask +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.$() -> AgentCore.Application.Runtime.Turn.ConversationTurnOrigin! +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.ConversationTurnOrigin(string? MessageId, string? ParentMessageId) -> void +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.Deconstruct(out string? MessageId, out string? ParentMessageId) -> void +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.Equals(AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? other) -> bool +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.MessageId.get -> string? +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.MessageId.init -> void +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.NamesParent.get -> bool +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.NamesParent.init -> void +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.ParentMessageId.get -> string? +AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.ParentMessageId.init -> void +AgentCore.Application.Runtime.Cut.TurnCut +AgentCore.Application.Runtime.Cut.TurnCut.$() -> AgentCore.Application.Runtime.Cut.TurnCut! +AgentCore.Application.Runtime.Cut.TurnCut.Deconstruct(out string? ShownText, out System.TimeSpan? Played) -> void +AgentCore.Application.Runtime.Cut.TurnCut.Equals(AgentCore.Application.Runtime.Cut.TurnCut? other) -> bool +AgentCore.Application.Runtime.Cut.TurnCut.Played.get -> System.TimeSpan? +AgentCore.Application.Runtime.Cut.TurnCut.Played.init -> void +AgentCore.Application.Runtime.Cut.TurnCut.ShownText.get -> string? +AgentCore.Application.Runtime.Cut.TurnCut.ShownText.init -> void +AgentCore.Application.Runtime.Cut.TurnCut.TurnCut(string? ShownText, System.TimeSpan? Played) -> void +AgentCore.Application.Runtime.Turn.Lifecycle.TurnFailureReasons AgentCore.Application.Scripting.ScriptRequest AgentCore.Application.Scripting.ScriptRequest.$() -> AgentCore.Application.Scripting.ScriptRequest! AgentCore.Application.Scripting.ScriptRequest.Code.get -> string! @@ -1239,8 +1206,8 @@ AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.CloseAsync(st AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.Count.get -> int 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.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 @@ -1372,6 +1339,8 @@ AgentCore.Application.Tools.ToolCallScope.TurnIndex.get -> int AgentCore.Application.Tools.ToolCallScope.TurnIndex.init -> void AgentCore.Application.Tools.ToolCallScope.Workspace.get -> string? AgentCore.Application.Tools.ToolCallScope.Workspace.init -> void +AgentCore.Application.Tools.ToolCallScope.Items.get -> System.Collections.Generic.IDictionary? +AgentCore.Application.Tools.ToolCallScope.Items.init -> void AgentCore.Application.Tools.ToolErrorResult AgentCore.Application.Tools.ToolResultJson AgentCore.Application.Transcript.ConversationMessage @@ -1456,16 +1425,18 @@ const AgentCore.Application.Evaluation.RetrievedDocumentsContext.RetrievedDocume const AgentCore.Application.Knowledge.NoQueryAnalyzer.AnalyzerName = "none" -> string! const AgentCore.Application.Knowledge.SourceLocatorCitationFormatter.FormatterName = "source-locator" -> string! const AgentCore.Application.Policy.StagePolicy.TurnEndedTrigger = "TurnEnded" -> string! -const AgentCore.Application.Runtime.ConversationSession.EmptyReplyReason = "the run returned an empty reply, so the turn spoke the fallback." -> string! -const AgentCore.Application.Runtime.ConversationSession.FallbackReply = "I am sorry. I could not finish that. Please say it again." -> string! -const AgentCore.Application.Runtime.ConversationSession.RunFaultReason = "the turn's run faulted, so it spoke the fallback." -> string! -const AgentCore.Application.Runtime.ConversationSession.ToolFailureReason = "a tool failed four times in a row, so the turn spoke the fallback." -> string! +const AgentCore.Application.Runtime.Session.ConversationSession.FallbackReply = "I am sorry. I could not finish that. Please say it again." -> string! +const AgentCore.Application.Runtime.Turn.Lifecycle.TurnFailureReasons.EmptyReply = "the run returned an empty reply, so the turn spoke the fallback." -> string! +const AgentCore.Application.Runtime.Turn.Lifecycle.TurnFailureReasons.RunFault = "the turn's run faulted, so it spoke the fallback." -> string! +const AgentCore.Application.Runtime.Turn.Lifecycle.TurnFailureReasons.ToolFailure = "a tool failed four times in a row, so the turn spoke the fallback." -> string! const AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudApiTokenName = "grafana-cloud-api-token" -> string! const AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudApiTokenVariable = "GRAFANA_CLOUD_API_TOKEN" -> string! const AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudInstanceIdName = "grafana-cloud-instance-id" -> string! const AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudInstanceIdVariable = "GRAFANA_CLOUD_INSTANCE_ID" -> string! const AgentCore.Application.Secrets.KnownSecrets.OpenAiApiKeyName = "openai-api-key" -> string! const AgentCore.Application.Secrets.KnownSecrets.OpenAiApiKeyVariable = "OPENAI_API_KEY" -> string! +const AgentCore.Application.Secrets.KnownSecrets.OpenAiWebhookSecretName = "openai-webhook-secret" -> string! +const AgentCore.Application.Secrets.KnownSecrets.OpenAiWebhookSecretVariable = "OPENAI_WEBHOOK_SECRET" -> string! const AgentCore.Application.Secrets.KnownSecrets.PostgresConnectionStringName = "postgres-connection-string" -> string! const AgentCore.Application.Secrets.KnownSecrets.PostgresConnectionStringVariable = "POSTGRES_CONNECTION_STRING" -> string! const AgentCore.Application.Secrets.KnownSecrets.QdrantApiKeyName = "qdrant-api-key" -> string! @@ -1667,15 +1638,12 @@ override AgentCore.Application.Knowledge.KnowledgePoint.ToString() -> string! override AgentCore.Application.Providers.VendorSeam.GetHashCode() -> int override AgentCore.Application.Runtime.AgentCoreAgent.Description.get -> string? override AgentCore.Application.Runtime.AgentCoreAgent.Name.get -> string? -override AgentCore.Application.Runtime.ConversationEvent.Equals(object? obj) -> bool -override AgentCore.Application.Runtime.ConversationEvent.GetHashCode() -> int -override AgentCore.Application.Runtime.ConversationEvent.ToString() -> string! -override AgentCore.Application.Runtime.ConversationTurnOrigin.Equals(object? obj) -> bool -override AgentCore.Application.Runtime.ConversationTurnOrigin.GetHashCode() -> int -override AgentCore.Application.Runtime.ConversationTurnOrigin.ToString() -> string! -override AgentCore.Application.Runtime.TurnCut.Equals(object? obj) -> bool -override AgentCore.Application.Runtime.TurnCut.GetHashCode() -> int -override AgentCore.Application.Runtime.TurnCut.ToString() -> string! +override AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.Equals(object? obj) -> bool +override AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.GetHashCode() -> int +override AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.ToString() -> string! +override AgentCore.Application.Runtime.Cut.TurnCut.Equals(object? obj) -> bool +override AgentCore.Application.Runtime.Cut.TurnCut.GetHashCode() -> int +override AgentCore.Application.Runtime.Cut.TurnCut.ToString() -> string! override AgentCore.Application.Scripting.ScriptRequest.Equals(object? obj) -> bool override AgentCore.Application.Scripting.ScriptRequest.GetHashCode() -> int override AgentCore.Application.Scripting.ScriptRequest.ToString() -> string! @@ -1896,14 +1864,11 @@ static AgentCore.Application.Llm.CompositeChatClientFactory.CreateAsync(AgentCor static AgentCore.Application.Providers.VendorAdapterSelector.Select(string! kind, System.Collections.Generic.IReadOnlyList! adapters, AgentCore.Application.Providers.VendorSeam seam) -> TAdapter static AgentCore.Application.Providers.VendorSeam.operator !=(AgentCore.Application.Providers.VendorSeam left, AgentCore.Application.Providers.VendorSeam right) -> bool static AgentCore.Application.Providers.VendorSeam.operator ==(AgentCore.Application.Providers.VendorSeam left, AgentCore.Application.Providers.VendorSeam right) -> bool -static AgentCore.Application.Runtime.ConversationEvent.operator !=(AgentCore.Application.Runtime.ConversationEvent? left, AgentCore.Application.Runtime.ConversationEvent? right) -> bool -static AgentCore.Application.Runtime.ConversationEvent.operator ==(AgentCore.Application.Runtime.ConversationEvent? left, AgentCore.Application.Runtime.ConversationEvent? right) -> bool -static AgentCore.Application.Runtime.ConversationObservers.Standard(AgentCore.Application.Ports.IAuditSinkPort! auditSink, Microsoft.Extensions.Logging.ILogger? logger, System.Collections.Generic.IEnumerable? hostObservers = null) -> System.Collections.Generic.IReadOnlyList! -static AgentCore.Application.Runtime.ConversationSessionFactory.CreateExtractor(AgentCore.Application.Configuration.Compilation.CompiledAgent! compiled, AgentCore.Application.Ports.IChatClientFactory! chatClients) -> AgentCore.Application.State.StateExtractor? -static AgentCore.Application.Runtime.ConversationTurnOrigin.operator !=(AgentCore.Application.Runtime.ConversationTurnOrigin? left, AgentCore.Application.Runtime.ConversationTurnOrigin? right) -> bool -static AgentCore.Application.Runtime.ConversationTurnOrigin.operator ==(AgentCore.Application.Runtime.ConversationTurnOrigin? left, AgentCore.Application.Runtime.ConversationTurnOrigin? right) -> bool -static AgentCore.Application.Runtime.TurnCut.operator !=(AgentCore.Application.Runtime.TurnCut? left, AgentCore.Application.Runtime.TurnCut? right) -> bool -static AgentCore.Application.Runtime.TurnCut.operator ==(AgentCore.Application.Runtime.TurnCut? left, AgentCore.Application.Runtime.TurnCut? right) -> bool +static AgentCore.Application.Runtime.Session.ConversationSessionFactory.CreateExtractor(AgentCore.Application.Configuration.Compilation.CompiledAgent! compiled, AgentCore.Application.Ports.IChatClientFactory! chatClients) -> AgentCore.Application.State.StateExtractor? +static AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.operator !=(AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? left, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? right) -> bool +static AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.operator ==(AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? left, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? right) -> bool +static AgentCore.Application.Runtime.Cut.TurnCut.operator !=(AgentCore.Application.Runtime.Cut.TurnCut? left, AgentCore.Application.Runtime.Cut.TurnCut? right) -> bool +static AgentCore.Application.Runtime.Cut.TurnCut.operator ==(AgentCore.Application.Runtime.Cut.TurnCut? left, AgentCore.Application.Runtime.Cut.TurnCut? right) -> bool static AgentCore.Application.Scripting.ScriptRequest.operator !=(AgentCore.Application.Scripting.ScriptRequest? left, AgentCore.Application.Scripting.ScriptRequest? right) -> bool static AgentCore.Application.Scripting.ScriptRequest.operator ==(AgentCore.Application.Scripting.ScriptRequest? left, AgentCore.Application.Scripting.ScriptRequest? right) -> bool static AgentCore.Application.Scripting.ScriptResult.Failed(string! error) -> AgentCore.Application.Scripting.ScriptResult! @@ -1956,6 +1921,7 @@ static readonly AgentCore.Application.Configuration.Schema.TelemetryProviderConf static readonly AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudApiToken -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudInstanceId -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Secrets.KnownSecrets.OpenAi -> AgentCore.Application.Secrets.SecretName! +static readonly AgentCore.Application.Secrets.KnownSecrets.OpenAiWebhook -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Secrets.KnownSecrets.PostgresConnectionString -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Secrets.KnownSecrets.Qdrant -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.DefaultIdleTimeout -> System.TimeSpan @@ -2077,6 +2043,7 @@ AgentCore.Application.Blobs.FileLink.Url.get -> System.Uri? AgentCore.Application.Blobs.FileLink.Url.init -> void AgentCore.Application.Conversation.Conversations AgentCore.Application.Conversation.Conversations.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.Conversation.Conversations.SaveStateAsync(string! conversationId, AgentCore.Application.Conversation.ConversationSessionState! state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.AppendMessageAsync(string! conversationId, Microsoft.Extensions.AI.ChatMessage! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.AttachPrincipalAsync(string! conversationId, string! principalKey, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.Conversations(AgentCore.Application.Ports.IConversationStore! conversations, AgentCore.Application.Ports.IBlobStore? blobs) -> void @@ -2146,6 +2113,8 @@ AgentCore.Application.Configuration.Schema.ShellConfiguration.MemoryMb.get -> in AgentCore.Application.Configuration.Schema.ShellConfiguration.MemoryMb.init -> void AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Secrets.get -> AgentCore.Application.Secrets.ResolvedSecrets? AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Secrets.init -> void +AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Hooks.get -> System.Collections.Generic.IReadOnlyList? +AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Hooks.init -> void AgentCore.Application.Conversation.StoredConversation AgentCore.Application.Conversation.StoredConversation.Conversation.get -> AgentCore.Application.Conversation.ConversationRecord! AgentCore.Application.Conversation.StoredConversation.Conversation.init -> void @@ -2286,13 +2255,13 @@ AgentCore.Application.Ports.IModelCatalogPort.LookupAsync(string! provider, stri const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyName = "opencode-go-api-key" -> string! const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyVariable = "OPENCODE_GO_API_KEY" -> string! static readonly AgentCore.Application.Secrets.KnownSecrets.OpenCodeGo -> AgentCore.Application.Secrets.SecretName! -AgentCore.Application.Ports.IConversationPort.StartTurnAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.ConversationSession.StartTurnAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.TurnRun -AgentCore.Application.Runtime.TurnRun.DisposeAsync() -> System.Threading.Tasks.ValueTask -AgentCore.Application.Runtime.TurnRun.TurnIndex.get -> int -AgentCore.Application.Runtime.TurnRun.TurnRun(int turnIndex, System.Collections.Generic.IAsyncEnumerable! updates) -> void -AgentCore.Application.Runtime.TurnRun.Updates.get -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.Application.Ports.IConversationPort.StartTurnAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Session.ConversationSession.StartTurnAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.Turn.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.Turn.Lifecycle.TurnRun +AgentCore.Application.Runtime.Turn.Lifecycle.TurnRun.DisposeAsync() -> System.Threading.Tasks.ValueTask +AgentCore.Application.Runtime.Turn.Lifecycle.TurnRun.TurnIndex.get -> int +AgentCore.Application.Runtime.Turn.Lifecycle.TurnRun.TurnRun(int turnIndex, System.Collections.Generic.IAsyncEnumerable! updates) -> void +AgentCore.Application.Runtime.Turn.Lifecycle.TurnRun.Updates.get -> System.Collections.Generic.IAsyncEnumerable! AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Filler.get -> System.Collections.Generic.IReadOnlyDictionary! AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Filler.init -> void AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.UserAway.get -> System.Text.Json.JsonElement @@ -2323,3 +2292,825 @@ AgentCore.Application.Conversation.Memory.InMemoryConversationStore.TryMarkBusyA AgentCore.Application.Conversation.Memory.InMemoryConversationStore.ClearBusyAsync(string! conversationId, string! holder, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.TryMarkBusyAsync(string! conversationId, string! holder, System.TimeSpan lease, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.ClearBusyAsync(string! conversationId, string! holder, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Hooks.Notices.ApprovalBy +AgentCore.Application.Hooks.Notices.ApprovalBy.Hook = 0 -> AgentCore.Application.Hooks.Notices.ApprovalBy +AgentCore.Application.Hooks.Notices.ApprovalBy.Human = 1 -> AgentCore.Application.Hooks.Notices.ApprovalBy +AgentCore.Application.Hooks.Notices.ApprovalBy.Rule = 2 -> AgentCore.Application.Hooks.Notices.ApprovalBy +AgentCore.Application.Hooks.Notices.ApprovalChanged +AgentCore.Application.Hooks.Notices.ApprovalChanged.ApprovalChanged(AgentCore.Application.Hooks.HookScope! Scope, string! ToolName, string! CallId, AgentCore.Application.Hooks.Notices.ApprovalState State, AgentCore.Application.Hooks.Notices.ApprovalBy By, string? Reason) -> void +AgentCore.Application.Hooks.Notices.ApprovalChanged.By.get -> AgentCore.Application.Hooks.Notices.ApprovalBy +AgentCore.Application.Hooks.Notices.ApprovalChanged.By.init -> void +AgentCore.Application.Hooks.Notices.ApprovalChanged.CallId.get -> string! +AgentCore.Application.Hooks.Notices.ApprovalChanged.CallId.init -> void +AgentCore.Application.Hooks.Notices.ApprovalChanged.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! ToolName, out string! CallId, out AgentCore.Application.Hooks.Notices.ApprovalState State, out AgentCore.Application.Hooks.Notices.ApprovalBy By, out string? Reason) -> void +AgentCore.Application.Hooks.Notices.ApprovalChanged.Equals(AgentCore.Application.Hooks.Notices.ApprovalChanged? other) -> bool +AgentCore.Application.Hooks.Notices.ApprovalChanged.Reason.get -> string? +AgentCore.Application.Hooks.Notices.ApprovalChanged.Reason.init -> void +AgentCore.Application.Hooks.Notices.ApprovalChanged.State.get -> AgentCore.Application.Hooks.Notices.ApprovalState +AgentCore.Application.Hooks.Notices.ApprovalChanged.State.init -> void +AgentCore.Application.Hooks.Notices.ApprovalChanged.ToolName.get -> string! +AgentCore.Application.Hooks.Notices.ApprovalChanged.ToolName.init -> void +AgentCore.Application.Hooks.Notices.ApprovalState +AgentCore.Application.Hooks.Notices.ApprovalState.Approved = 1 -> AgentCore.Application.Hooks.Notices.ApprovalState +AgentCore.Application.Hooks.Notices.ApprovalState.Asked = 0 -> AgentCore.Application.Hooks.Notices.ApprovalState +AgentCore.Application.Hooks.Notices.ApprovalState.Denied = 2 -> AgentCore.Application.Hooks.Notices.ApprovalState +AgentCore.Application.Hooks.Notices.CallEnd +AgentCore.Application.Hooks.Notices.CallEnd.$() -> AgentCore.Application.Hooks.Notices.CallEnd! +AgentCore.Application.Hooks.Notices.CallEnd.CallEnd(string! CallId, double Seconds, string? Cause) -> void +AgentCore.Application.Hooks.Notices.CallEnd.CallId.get -> string! +AgentCore.Application.Hooks.Notices.CallEnd.CallId.init -> void +AgentCore.Application.Hooks.Notices.CallEnd.Cause.get -> string? +AgentCore.Application.Hooks.Notices.CallEnd.Cause.init -> void +AgentCore.Application.Hooks.Notices.CallEnd.Deconstruct(out string! CallId, out double Seconds, out string? Cause) -> void +AgentCore.Application.Hooks.Notices.CallEnd.Equals(AgentCore.Application.Hooks.Notices.CallEnd? other) -> bool +AgentCore.Application.Hooks.Notices.CallEnd.Seconds.get -> double +AgentCore.Application.Hooks.Notices.CallEnd.Seconds.init -> void +AgentCore.Application.Hooks.Gates.CallRefusal +AgentCore.Application.Hooks.Gates.CallRefusal.Busy = 1 -> AgentCore.Application.Hooks.Gates.CallRefusal +AgentCore.Application.Hooks.Gates.CallRefusal.Declined = 0 -> AgentCore.Application.Hooks.Gates.CallRefusal +AgentCore.Application.Hooks.Gates.CallRefusal.Unavailable = 2 -> AgentCore.Application.Hooks.Gates.CallRefusal +AgentCore.Application.Hooks.Notices.CallStarted +AgentCore.Application.Hooks.Notices.CallStarted.CallId.get -> string! +AgentCore.Application.Hooks.Notices.CallStarted.CallId.init -> void +AgentCore.Application.Hooks.Notices.CallStarted.CallStarted(AgentCore.Application.Hooks.HookScope! Scope, string! CallId, string? From, string? To, string! Transport) -> void +AgentCore.Application.Hooks.Notices.CallStarted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! CallId, out string? From, out string? To, out string! Transport) -> void +AgentCore.Application.Hooks.Notices.CallStarted.Equals(AgentCore.Application.Hooks.Notices.CallStarted? other) -> bool +AgentCore.Application.Hooks.Notices.CallStarted.From.get -> string? +AgentCore.Application.Hooks.Notices.CallStarted.From.init -> void +AgentCore.Application.Hooks.Notices.CallStarted.To.get -> string? +AgentCore.Application.Hooks.Notices.CallStarted.To.init -> void +AgentCore.Application.Hooks.Notices.CallStarted.Transport.get -> string! +AgentCore.Application.Hooks.Notices.CallStarted.Transport.init -> void +AgentCore.Application.Hooks.Notices.Compacted +AgentCore.Application.Hooks.Notices.Compacted.Compacted(AgentCore.Application.Hooks.HookScope! Scope, int MessagesBefore, int MessagesAfter, AgentCore.Application.Hooks.Notices.CompactionOutcome Outcome) -> void +AgentCore.Application.Hooks.Notices.Compacted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out int MessagesBefore, out int MessagesAfter, out AgentCore.Application.Hooks.Notices.CompactionOutcome Outcome) -> void +AgentCore.Application.Hooks.Notices.Compacted.Equals(AgentCore.Application.Hooks.Notices.Compacted? other) -> bool +AgentCore.Application.Hooks.Notices.Compacted.MessagesAfter.get -> int +AgentCore.Application.Hooks.Notices.Compacted.MessagesAfter.init -> void +AgentCore.Application.Hooks.Notices.Compacted.MessagesBefore.get -> int +AgentCore.Application.Hooks.Notices.Compacted.MessagesBefore.init -> void +AgentCore.Application.Hooks.Notices.Compacted.Outcome.get -> AgentCore.Application.Hooks.Notices.CompactionOutcome +AgentCore.Application.Hooks.Notices.Compacted.Outcome.init -> void +AgentCore.Application.Hooks.Notices.CompactionOutcome +AgentCore.Application.Hooks.Notices.CompactionOutcome.Cancelled = 3 -> AgentCore.Application.Hooks.Notices.CompactionOutcome +AgentCore.Application.Hooks.Notices.CompactionOutcome.Compacted = 0 -> AgentCore.Application.Hooks.Notices.CompactionOutcome +AgentCore.Application.Hooks.Notices.CompactionOutcome.Failed = 2 -> AgentCore.Application.Hooks.Notices.CompactionOutcome +AgentCore.Application.Hooks.Notices.CompactionOutcome.Unchanged = 1 -> AgentCore.Application.Hooks.Notices.CompactionOutcome +AgentCore.Application.Hooks.Notices.ConversationEnded +AgentCore.Application.Hooks.Notices.ConversationEnded.Call.get -> AgentCore.Application.Hooks.Notices.CallEnd? +AgentCore.Application.Hooks.Notices.ConversationEnded.Call.init -> void +AgentCore.Application.Hooks.Notices.ConversationEnded.ConversationEnded(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Domain.Audit.ConversationEndReason Reason, string? TerminalStage, AgentCore.Application.Hooks.Notices.CallEnd? Call) -> void +AgentCore.Application.Hooks.Notices.ConversationEnded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Domain.Audit.ConversationEndReason Reason, out string? TerminalStage, out AgentCore.Application.Hooks.Notices.CallEnd? Call) -> void +AgentCore.Application.Hooks.Notices.ConversationEnded.Equals(AgentCore.Application.Hooks.Notices.ConversationEnded? other) -> bool +AgentCore.Application.Hooks.Notices.ConversationEnded.Reason.get -> AgentCore.Domain.Audit.ConversationEndReason +AgentCore.Application.Hooks.Notices.ConversationEnded.Reason.init -> void +AgentCore.Application.Hooks.Notices.ConversationEnded.TerminalStage.get -> string? +AgentCore.Application.Hooks.Notices.ConversationEnded.TerminalStage.init -> void +AgentCore.Application.Hooks.Notices.ConversationOrigin +AgentCore.Application.Hooks.Notices.ConversationOrigin.New = 0 -> AgentCore.Application.Hooks.Notices.ConversationOrigin +AgentCore.Application.Hooks.Notices.ConversationOrigin.Reloaded = 2 -> AgentCore.Application.Hooks.Notices.ConversationOrigin +AgentCore.Application.Hooks.Notices.ConversationOrigin.Resumed = 1 -> AgentCore.Application.Hooks.Notices.ConversationOrigin +AgentCore.Application.Hooks.Notices.ConversationStarted +AgentCore.Application.Hooks.Notices.ConversationStarted.ConversationStarted(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.ConversationOrigin Origin) -> void +AgentCore.Application.Hooks.Notices.ConversationStarted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.ConversationOrigin Origin) -> void +AgentCore.Application.Hooks.Notices.ConversationStarted.Equals(AgentCore.Application.Hooks.Notices.ConversationStarted? other) -> bool +AgentCore.Application.Hooks.Notices.ConversationStarted.Origin.get -> AgentCore.Application.Hooks.Notices.ConversationOrigin +AgentCore.Application.Hooks.Notices.ConversationStarted.Origin.init -> void +AgentCore.Application.Hooks.Notices.ConversationUnloaded +AgentCore.Application.Hooks.Notices.ConversationUnloaded.Cause.get -> AgentCore.Application.Hooks.Notices.UnloadCause +AgentCore.Application.Hooks.Notices.ConversationUnloaded.Cause.init -> void +AgentCore.Application.Hooks.Notices.ConversationUnloaded.ConversationUnloaded(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.UnloadCause Cause) -> void +AgentCore.Application.Hooks.Notices.ConversationUnloaded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.UnloadCause Cause) -> void +AgentCore.Application.Hooks.Notices.ConversationUnloaded.Equals(AgentCore.Application.Hooks.Notices.ConversationUnloaded? other) -> bool +AgentCore.Application.Hooks.Gates.EntryTransport +AgentCore.Application.Hooks.Gates.EntryTransport.Call = 1 -> AgentCore.Application.Hooks.Gates.EntryTransport +AgentCore.Application.Hooks.Gates.EntryTransport.Http = 0 -> AgentCore.Application.Hooks.Gates.EntryTransport +AgentCore.Application.Hooks.Notices.Fault +AgentCore.Application.Hooks.Notices.Fault.Cause.get -> System.Exception? +AgentCore.Application.Hooks.Notices.Fault.Cause.init -> void +AgentCore.Application.Hooks.Notices.Fault.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.FaultKind Kind, out string! Message, out System.Exception? Cause) -> void +AgentCore.Application.Hooks.Notices.Fault.Equals(AgentCore.Application.Hooks.Notices.Fault? other) -> bool +AgentCore.Application.Hooks.Notices.Fault.Fault(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.FaultKind Kind, string! Message, System.Exception? Cause) -> void +AgentCore.Application.Hooks.Notices.Fault.Kind.get -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.Fault.Kind.init -> void +AgentCore.Application.Hooks.Notices.Fault.Message.get -> string! +AgentCore.Application.Hooks.Notices.Fault.Message.init -> void +AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.BusyMarkFailed = 6 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.BusyMarkLost = 4 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.DenialRoundsExhausted = 7 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.ExtractionFailed = 3 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.HookFailed = 5 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.StateRestorePartial = 2 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.TranscriptResyncFailed = 1 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FaultKind.TranscriptWriteFailed = 0 -> AgentCore.Application.Hooks.Notices.FaultKind +AgentCore.Application.Hooks.Notices.FilePublished +AgentCore.Application.Hooks.Notices.FilePublished.BlobKey.get -> string! +AgentCore.Application.Hooks.Notices.FilePublished.BlobKey.init -> void +AgentCore.Application.Hooks.Notices.FilePublished.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! Name, out long Length, out string! MediaType, out string! BlobKey) -> void +AgentCore.Application.Hooks.Notices.FilePublished.Equals(AgentCore.Application.Hooks.Notices.FilePublished? other) -> bool +AgentCore.Application.Hooks.Notices.FilePublished.FilePublished(AgentCore.Application.Hooks.HookScope! Scope, string! Name, long Length, string! MediaType, string! BlobKey) -> void +AgentCore.Application.Hooks.Notices.FilePublished.Length.get -> long +AgentCore.Application.Hooks.Notices.FilePublished.Length.init -> void +AgentCore.Application.Hooks.Notices.FilePublished.MediaType.get -> string! +AgentCore.Application.Hooks.Notices.FilePublished.MediaType.init -> void +AgentCore.Application.Hooks.Notices.FilePublished.Name.get -> string! +AgentCore.Application.Hooks.Notices.FilePublished.Name.init -> void +AgentCore.Application.Hooks.HookNotice +AgentCore.Application.Hooks.HookNotice.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope) -> void +AgentCore.Application.Hooks.HookNotice.EventId.get -> System.Guid +AgentCore.Application.Hooks.HookNotice.EventId.init -> void +AgentCore.Application.Hooks.HookNotice.HookNotice(AgentCore.Application.Hooks.HookNotice! original) -> void +AgentCore.Application.Hooks.HookNotice.HookNotice(AgentCore.Application.Hooks.HookScope! Scope) -> void +AgentCore.Application.Hooks.HookNotice.Scope.get -> AgentCore.Application.Hooks.HookScope! +AgentCore.Application.Hooks.HookNotice.Scope.init -> void +AgentCore.Application.Hooks.HookScope +AgentCore.Application.Hooks.HookScope.$() -> AgentCore.Application.Hooks.HookScope! +AgentCore.Application.Hooks.HookScope.ConversationId.get -> string? +AgentCore.Application.Hooks.HookScope.ConversationId.init -> void +AgentCore.Application.Hooks.HookScope.Deconstruct(out string? ConversationId, out string? Entry, out int? TurnIndex, out string? Stage, out System.Guid SessionId, out long Sequence, out System.DateTimeOffset OccurredAt) -> void +AgentCore.Application.Hooks.HookScope.Entry.get -> string? +AgentCore.Application.Hooks.HookScope.Entry.init -> void +AgentCore.Application.Hooks.HookScope.Equals(AgentCore.Application.Hooks.HookScope? other) -> bool +AgentCore.Application.Hooks.HookScope.HookScope(string? ConversationId, string? Entry, int? TurnIndex, string? Stage, System.Guid SessionId, long Sequence, System.DateTimeOffset OccurredAt) -> void +AgentCore.Application.Hooks.HookScope.OccurredAt.get -> System.DateTimeOffset +AgentCore.Application.Hooks.HookScope.OccurredAt.init -> void +AgentCore.Application.Hooks.HookScope.Sequence.get -> long +AgentCore.Application.Hooks.HookScope.Sequence.init -> void +AgentCore.Application.Hooks.HookScope.SessionId.get -> System.Guid +AgentCore.Application.Hooks.HookScope.SessionId.init -> void +AgentCore.Application.Hooks.HookScope.Stage.get -> string? +AgentCore.Application.Hooks.HookScope.Stage.init -> void +AgentCore.Application.Hooks.HookScope.TurnIndex.get -> int? +AgentCore.Application.Hooks.HookScope.TurnIndex.init -> void +AgentCore.Application.Hooks.Notices.HostStarted +AgentCore.Application.Hooks.Notices.HostStarted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out System.Collections.Generic.IReadOnlyList! Entries, out int ToolCount) -> void +AgentCore.Application.Hooks.Notices.HostStarted.Entries.get -> System.Collections.Generic.IReadOnlyList! +AgentCore.Application.Hooks.Notices.HostStarted.Entries.init -> void +AgentCore.Application.Hooks.Notices.HostStarted.Equals(AgentCore.Application.Hooks.Notices.HostStarted? other) -> bool +AgentCore.Application.Hooks.Notices.HostStarted.HostStarted(AgentCore.Application.Hooks.HookScope! Scope, System.Collections.Generic.IReadOnlyList! Entries, int ToolCount) -> void +AgentCore.Application.Hooks.Notices.HostStarted.ToolCount.get -> int +AgentCore.Application.Hooks.Notices.HostStarted.ToolCount.init -> void +AgentCore.Application.Hooks.Notices.HostStopping +AgentCore.Application.Hooks.Notices.HostStopping.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope) -> void +AgentCore.Application.Hooks.Notices.HostStopping.Equals(AgentCore.Application.Hooks.Notices.HostStopping? other) -> bool +AgentCore.Application.Hooks.Notices.HostStopping.HostStopping(AgentCore.Application.Hooks.HookScope! Scope) -> void +AgentCore.Application.Hooks.Notices.InputModerated +AgentCore.Application.Hooks.Notices.InputModerated.Categories.get -> System.Collections.Generic.IReadOnlyList! +AgentCore.Application.Hooks.Notices.InputModerated.Categories.init -> void +AgentCore.Application.Hooks.Notices.InputModerated.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.InputVerdict Verdict, out System.Collections.Generic.IReadOnlyList! Categories, out AgentCore.Application.Hooks.Notices.ModerationUnavailableReason? Reason) -> void +AgentCore.Application.Hooks.Notices.InputModerated.Equals(AgentCore.Application.Hooks.Notices.InputModerated? other) -> bool +AgentCore.Application.Hooks.Notices.InputModerated.InputModerated(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.InputVerdict Verdict, System.Collections.Generic.IReadOnlyList! Categories, AgentCore.Application.Hooks.Notices.ModerationUnavailableReason? Reason) -> void +AgentCore.Application.Hooks.Notices.InputModerated.Reason.get -> AgentCore.Application.Hooks.Notices.ModerationUnavailableReason? +AgentCore.Application.Hooks.Notices.InputModerated.Reason.init -> void +AgentCore.Application.Hooks.Notices.InputModerated.Verdict.get -> AgentCore.Application.Hooks.Notices.InputVerdict +AgentCore.Application.Hooks.Notices.InputModerated.Verdict.init -> void +AgentCore.Application.Hooks.Notices.InputVerdict +AgentCore.Application.Hooks.Notices.InputVerdict.Clean = 0 -> AgentCore.Application.Hooks.Notices.InputVerdict +AgentCore.Application.Hooks.Notices.InputVerdict.Flagged = 1 -> AgentCore.Application.Hooks.Notices.InputVerdict +AgentCore.Application.Hooks.Notices.InputVerdict.Unavailable = 2 -> AgentCore.Application.Hooks.Notices.InputVerdict +AgentCore.Application.Hooks.Notices.KnowledgeSearched +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! Query, out AgentCore.Domain.Knowledge.KnowledgeScope? SearchScope, out int Hits, out System.TimeSpan Latency, out string? Failure) -> void +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Equals(AgentCore.Application.Hooks.Notices.KnowledgeSearched? other) -> bool +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Failure.get -> string? +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Failure.init -> void +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Hits.get -> int +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Hits.init -> void +AgentCore.Application.Hooks.Notices.KnowledgeSearched.KnowledgeSearched(AgentCore.Application.Hooks.HookScope! Scope, string! Query, AgentCore.Domain.Knowledge.KnowledgeScope? SearchScope, int Hits, System.TimeSpan Latency, string? Failure) -> void +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Latency.get -> System.TimeSpan +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Latency.init -> void +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Query.get -> string! +AgentCore.Application.Hooks.Notices.KnowledgeSearched.Query.init -> void +AgentCore.Application.Hooks.Notices.KnowledgeSearched.SearchScope.get -> AgentCore.Domain.Knowledge.KnowledgeScope? +AgentCore.Application.Hooks.Notices.KnowledgeSearched.SearchScope.init -> void +AgentCore.Application.Hooks.Notices.LatencyMetric +AgentCore.Application.Hooks.Notices.LatencyMetric.BargeIn = 3 -> AgentCore.Application.Hooks.Notices.LatencyMetric +AgentCore.Application.Hooks.Notices.LatencyMetric.TimeToFirstSpeech = 1 -> AgentCore.Application.Hooks.Notices.LatencyMetric +AgentCore.Application.Hooks.Notices.LatencyMetric.TimeToFirstToken = 0 -> AgentCore.Application.Hooks.Notices.LatencyMetric +AgentCore.Application.Hooks.Notices.LatencyMetric.TimeToReplyEnd = 2 -> AgentCore.Application.Hooks.Notices.LatencyMetric +AgentCore.Application.Hooks.Notices.LineSpoken +AgentCore.Application.Hooks.Notices.LineSpoken.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.Speaker Speaker, out string! Text, out System.DateTimeOffset StartedAt, out System.DateTimeOffset EndedAt) -> void +AgentCore.Application.Hooks.Notices.LineSpoken.EndedAt.get -> System.DateTimeOffset +AgentCore.Application.Hooks.Notices.LineSpoken.EndedAt.init -> void +AgentCore.Application.Hooks.Notices.LineSpoken.Equals(AgentCore.Application.Hooks.Notices.LineSpoken? other) -> bool +AgentCore.Application.Hooks.Notices.LineSpoken.LineSpoken(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.Speaker Speaker, string! Text, System.DateTimeOffset StartedAt, System.DateTimeOffset EndedAt) -> void +AgentCore.Application.Hooks.Notices.LineSpoken.Speaker.get -> AgentCore.Application.Hooks.Notices.Speaker +AgentCore.Application.Hooks.Notices.LineSpoken.Speaker.init -> void +AgentCore.Application.Hooks.Notices.LineSpoken.StartedAt.get -> System.DateTimeOffset +AgentCore.Application.Hooks.Notices.LineSpoken.StartedAt.init -> void +AgentCore.Application.Hooks.Notices.LineSpoken.Text.get -> string! +AgentCore.Application.Hooks.Notices.LineSpoken.Text.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled +AgentCore.Application.Hooks.Notices.ModelCalled.CachedInputTokens.get -> long? +AgentCore.Application.Hooks.Notices.ModelCalled.CachedInputTokens.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string? ModelId, out int Round, out long? InputTokens, out long? OutputTokens, out long? CachedInputTokens, out System.TimeSpan Latency, out string? FinishReason, out string? Failure) -> void +AgentCore.Application.Hooks.Notices.ModelCalled.Equals(AgentCore.Application.Hooks.Notices.ModelCalled? other) -> bool +AgentCore.Application.Hooks.Notices.ModelCalled.Failure.get -> string? +AgentCore.Application.Hooks.Notices.ModelCalled.Failure.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.FinishReason.get -> string? +AgentCore.Application.Hooks.Notices.ModelCalled.FinishReason.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.InputTokens.get -> long? +AgentCore.Application.Hooks.Notices.ModelCalled.InputTokens.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.Latency.get -> System.TimeSpan +AgentCore.Application.Hooks.Notices.ModelCalled.Latency.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.ModelCalled(AgentCore.Application.Hooks.HookScope! Scope, string? ModelId, int Round, long? InputTokens, long? OutputTokens, long? CachedInputTokens, System.TimeSpan Latency, string? FinishReason, string? Failure) -> void +AgentCore.Application.Hooks.Notices.ModelCalled.ModelId.get -> string? +AgentCore.Application.Hooks.Notices.ModelCalled.ModelId.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.OutputTokens.get -> long? +AgentCore.Application.Hooks.Notices.ModelCalled.OutputTokens.init -> void +AgentCore.Application.Hooks.Notices.ModelCalled.Round.get -> int +AgentCore.Application.Hooks.Notices.ModelCalled.Round.init -> void +AgentCore.Application.Hooks.Notices.ModerationUnavailableReason +AgentCore.Application.Hooks.Notices.ModerationUnavailableReason.Threw = 1 -> AgentCore.Application.Hooks.Notices.ModerationUnavailableReason +AgentCore.Application.Hooks.Notices.ModerationUnavailableReason.TimedOut = 0 -> AgentCore.Application.Hooks.Notices.ModerationUnavailableReason +AgentCore.Application.Hooks.Notices.ReplyCut +AgentCore.Application.Hooks.Notices.ReplyCut.AmendsEventId.get -> System.Guid +AgentCore.Application.Hooks.Notices.ReplyCut.AmendsEventId.init -> void +AgentCore.Application.Hooks.Notices.ReplyCut.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! HeardText, out System.TimeSpan? Played, out System.Guid AmendsEventId) -> void +AgentCore.Application.Hooks.Notices.ReplyCut.Equals(AgentCore.Application.Hooks.Notices.ReplyCut? other) -> bool +AgentCore.Application.Hooks.Notices.ReplyCut.HeardText.get -> string! +AgentCore.Application.Hooks.Notices.ReplyCut.HeardText.init -> void +AgentCore.Application.Hooks.Notices.ReplyCut.Played.get -> System.TimeSpan? +AgentCore.Application.Hooks.Notices.ReplyCut.Played.init -> void +AgentCore.Application.Hooks.Notices.ReplyCut.ReplyCut(AgentCore.Application.Hooks.HookScope! Scope, string! HeardText, System.TimeSpan? Played, System.Guid AmendsEventId) -> void +AgentCore.Application.Hooks.Notices.ReplyUpdated +AgentCore.Application.Hooks.Notices.ReplyUpdated.Author.get -> string? +AgentCore.Application.Hooks.Notices.ReplyUpdated.Author.init -> void +AgentCore.Application.Hooks.Notices.ReplyUpdated.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! Text, out string? Author) -> void +AgentCore.Application.Hooks.Notices.ReplyUpdated.Equals(AgentCore.Application.Hooks.Notices.ReplyUpdated? other) -> bool +AgentCore.Application.Hooks.Notices.ReplyUpdated.ReplyUpdated(AgentCore.Application.Hooks.HookScope! Scope, string! Text, string? Author) -> void +AgentCore.Application.Hooks.Notices.ReplyUpdated.Text.get -> string! +AgentCore.Application.Hooks.Notices.ReplyUpdated.Text.init -> void +AgentCore.Application.Hooks.Notices.RetentionSwept +AgentCore.Application.Hooks.Notices.RetentionSwept.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out int Deleted) -> void +AgentCore.Application.Hooks.Notices.RetentionSwept.Deleted.get -> int +AgentCore.Application.Hooks.Notices.RetentionSwept.Deleted.init -> void +AgentCore.Application.Hooks.Notices.RetentionSwept.Equals(AgentCore.Application.Hooks.Notices.RetentionSwept? other) -> bool +AgentCore.Application.Hooks.Notices.RetentionSwept.RetentionSwept(AgentCore.Application.Hooks.HookScope! Scope, int Deleted) -> void +AgentCore.Application.Hooks.Notices.SkillLoaded +AgentCore.Application.Hooks.Notices.SkillLoaded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! Name, out bool Pinned) -> void +AgentCore.Application.Hooks.Notices.SkillLoaded.Equals(AgentCore.Application.Hooks.Notices.SkillLoaded? other) -> bool +AgentCore.Application.Hooks.Notices.SkillLoaded.Name.get -> string! +AgentCore.Application.Hooks.Notices.SkillLoaded.Name.init -> void +AgentCore.Application.Hooks.Notices.SkillLoaded.Pinned.get -> bool +AgentCore.Application.Hooks.Notices.SkillLoaded.Pinned.init -> void +AgentCore.Application.Hooks.Notices.SkillLoaded.SkillLoaded(AgentCore.Application.Hooks.HookScope! Scope, string! Name, bool Pinned) -> void +AgentCore.Application.Hooks.Notices.Speaker +AgentCore.Application.Hooks.Notices.Speaker.Agent = 1 -> AgentCore.Application.Hooks.Notices.Speaker +AgentCore.Application.Hooks.Notices.Speaker.Caller = 0 -> AgentCore.Application.Hooks.Notices.Speaker +AgentCore.Application.Hooks.Notices.StageChanged +AgentCore.Application.Hooks.Notices.StageChanged.After.get -> string! +AgentCore.Application.Hooks.Notices.StageChanged.After.init -> void +AgentCore.Application.Hooks.Notices.StageChanged.Before.get -> string! +AgentCore.Application.Hooks.Notices.StageChanged.Before.init -> void +AgentCore.Application.Hooks.Notices.StageChanged.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! Before, out string! After) -> void +AgentCore.Application.Hooks.Notices.StageChanged.Equals(AgentCore.Application.Hooks.Notices.StageChanged? other) -> bool +AgentCore.Application.Hooks.Notices.StageChanged.StageChanged(AgentCore.Application.Hooks.HookScope! Scope, string! Before, string! After) -> void +AgentCore.Application.Hooks.Notices.SubagentEnded +AgentCore.Application.Hooks.Notices.SubagentEnded.AgentId.get -> string! +AgentCore.Application.Hooks.Notices.SubagentEnded.AgentId.init -> void +AgentCore.Application.Hooks.Notices.SubagentEnded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string? ParentToolCallId, out string! AgentId, out AgentCore.Application.Hooks.Notices.SubagentOutcome Outcome, out System.TimeSpan Duration) -> void +AgentCore.Application.Hooks.Notices.SubagentEnded.Duration.get -> System.TimeSpan +AgentCore.Application.Hooks.Notices.SubagentEnded.Duration.init -> void +AgentCore.Application.Hooks.Notices.SubagentEnded.Equals(AgentCore.Application.Hooks.Notices.SubagentEnded? other) -> bool +AgentCore.Application.Hooks.Notices.SubagentEnded.Outcome.get -> AgentCore.Application.Hooks.Notices.SubagentOutcome +AgentCore.Application.Hooks.Notices.SubagentEnded.Outcome.init -> void +AgentCore.Application.Hooks.Notices.SubagentEnded.ParentToolCallId.get -> string? +AgentCore.Application.Hooks.Notices.SubagentEnded.ParentToolCallId.init -> void +AgentCore.Application.Hooks.Notices.SubagentEnded.SubagentEnded(AgentCore.Application.Hooks.HookScope! Scope, string? ParentToolCallId, string! AgentId, AgentCore.Application.Hooks.Notices.SubagentOutcome Outcome, System.TimeSpan Duration) -> void +AgentCore.Application.Hooks.Notices.SubagentOutcome +AgentCore.Application.Hooks.Notices.SubagentOutcome.Answered = 0 -> AgentCore.Application.Hooks.Notices.SubagentOutcome +AgentCore.Application.Hooks.Notices.SubagentOutcome.Cancelled = 2 -> AgentCore.Application.Hooks.Notices.SubagentOutcome +AgentCore.Application.Hooks.Notices.SubagentOutcome.Faulted = 1 -> AgentCore.Application.Hooks.Notices.SubagentOutcome +AgentCore.Application.Hooks.Notices.SubagentStarted +AgentCore.Application.Hooks.Notices.SubagentStarted.AgentId.get -> string! +AgentCore.Application.Hooks.Notices.SubagentStarted.AgentId.init -> void +AgentCore.Application.Hooks.Notices.SubagentStarted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string? ParentToolCallId, out string! AgentId) -> void +AgentCore.Application.Hooks.Notices.SubagentStarted.Equals(AgentCore.Application.Hooks.Notices.SubagentStarted? other) -> bool +AgentCore.Application.Hooks.Notices.SubagentStarted.ParentToolCallId.get -> string? +AgentCore.Application.Hooks.Notices.SubagentStarted.ParentToolCallId.init -> void +AgentCore.Application.Hooks.Notices.SubagentStarted.SubagentStarted(AgentCore.Application.Hooks.HookScope! Scope, string? ParentToolCallId, string! AgentId) -> void +AgentCore.Application.Hooks.Notices.ToolCalled +AgentCore.Application.Hooks.Notices.ToolCalled.CallId.get -> string! +AgentCore.Application.Hooks.Notices.ToolCalled.CallId.init -> void +AgentCore.Application.Hooks.Notices.ToolCalled.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! ToolName, out string! CallId, out System.TimeSpan Duration, out AgentCore.Application.Hooks.Notices.ToolOutcome Outcome, out bool Fatal, out AgentCore.Domain.Audit.ToolFailureKind? FailureKind, out string? Failure) -> void +AgentCore.Application.Hooks.Notices.ToolCalled.Duration.get -> System.TimeSpan +AgentCore.Application.Hooks.Notices.ToolCalled.Duration.init -> void +AgentCore.Application.Hooks.Notices.ToolCalled.Equals(AgentCore.Application.Hooks.Notices.ToolCalled? other) -> bool +AgentCore.Application.Hooks.Notices.ToolCalled.Failure.get -> string? +AgentCore.Application.Hooks.Notices.ToolCalled.Failure.init -> void +AgentCore.Application.Hooks.Notices.ToolCalled.FailureKind.get -> AgentCore.Domain.Audit.ToolFailureKind? +AgentCore.Application.Hooks.Notices.ToolCalled.FailureKind.init -> void +AgentCore.Application.Hooks.Notices.ToolCalled.Fatal.get -> bool +AgentCore.Application.Hooks.Notices.ToolCalled.Fatal.init -> void +AgentCore.Application.Hooks.Notices.ToolCalled.Outcome.get -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolCalled.Outcome.init -> void +AgentCore.Application.Hooks.Notices.ToolCalled.ToolCalled(AgentCore.Application.Hooks.HookScope! Scope, string! ToolName, string! CallId, System.TimeSpan Duration, AgentCore.Application.Hooks.Notices.ToolOutcome Outcome, bool Fatal, AgentCore.Domain.Audit.ToolFailureKind? FailureKind, string? Failure) -> void +AgentCore.Application.Hooks.Notices.ToolCalled.ToolName.get -> string! +AgentCore.Application.Hooks.Notices.ToolCalled.ToolName.init -> void +AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.Blocked = 2 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.Cached = 4 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.Failed = 1 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.Ok = 0 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.Responded = 3 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.TimedOut = 5 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.ToolOutcome.Undeclared = 6 -> AgentCore.Application.Hooks.Notices.ToolOutcome +AgentCore.Application.Hooks.Notices.TurnCompleted +AgentCore.Application.Hooks.Notices.TurnCompleted.Cause.get -> System.Exception? +AgentCore.Application.Hooks.Notices.TurnCompleted.Cause.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.TurnOutcome Outcome, out string! UserText, out string! ReplyText, out string! StageBefore, out string! StageAfter, out System.TimeSpan Duration, out string? Failure, out bool FailedInTool, out System.Exception? Cause) -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.Duration.get -> System.TimeSpan +AgentCore.Application.Hooks.Notices.TurnCompleted.Duration.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.Equals(AgentCore.Application.Hooks.Notices.TurnCompleted? other) -> bool +AgentCore.Application.Hooks.Notices.TurnCompleted.FailedInTool.get -> bool +AgentCore.Application.Hooks.Notices.TurnCompleted.FailedInTool.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.Failure.get -> string? +AgentCore.Application.Hooks.Notices.TurnCompleted.Failure.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.Outcome.get -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnCompleted.Outcome.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.ReplyText.get -> string! +AgentCore.Application.Hooks.Notices.TurnCompleted.ReplyText.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.StageAfter.get -> string! +AgentCore.Application.Hooks.Notices.TurnCompleted.StageAfter.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.StageBefore.get -> string! +AgentCore.Application.Hooks.Notices.TurnCompleted.StageBefore.init -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.TurnCompleted(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.TurnOutcome Outcome, string! UserText, string! ReplyText, string! StageBefore, string! StageAfter, System.TimeSpan Duration, string? Failure, bool FailedInTool, System.Exception? Cause) -> void +AgentCore.Application.Hooks.Notices.TurnCompleted.UserText.get -> string! +AgentCore.Application.Hooks.Notices.TurnCompleted.UserText.init -> void +AgentCore.Application.Hooks.Notices.TurnLatency +AgentCore.Application.Hooks.Notices.TurnLatency.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.LatencyMetric Metric, out System.TimeSpan Value) -> void +AgentCore.Application.Hooks.Notices.TurnLatency.Equals(AgentCore.Application.Hooks.Notices.TurnLatency? other) -> bool +AgentCore.Application.Hooks.Notices.TurnLatency.Metric.get -> AgentCore.Application.Hooks.Notices.LatencyMetric +AgentCore.Application.Hooks.Notices.TurnLatency.Metric.init -> void +AgentCore.Application.Hooks.Notices.TurnLatency.TurnLatency(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.LatencyMetric Metric, System.TimeSpan Value) -> void +AgentCore.Application.Hooks.Notices.TurnLatency.Value.get -> System.TimeSpan +AgentCore.Application.Hooks.Notices.TurnLatency.Value.init -> void +AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnOutcome.Answered = 0 -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnOutcome.Blocked = 3 -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnOutcome.Cancelled = 4 -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnOutcome.Empty = 2 -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnOutcome.Fallback = 1 -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnOutcome.Faulted = 5 -> AgentCore.Application.Hooks.Notices.TurnOutcome +AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.Busy = 0 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.Conflict = 1 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.Disposed = 5 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.Dropped = 3 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.Gone = 2 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.InUse = 6 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusal.Terminal = 4 -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefusalTokens +AgentCore.Application.Hooks.Notices.TurnRefused +AgentCore.Application.Hooks.Notices.TurnRefused.AfterEnd.get -> bool +AgentCore.Application.Hooks.Notices.TurnRefused.AfterEnd.init -> void +AgentCore.Application.Hooks.Notices.TurnRefused.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.TurnRefusal Reason, out bool AfterEnd) -> void +AgentCore.Application.Hooks.Notices.TurnRefused.Equals(AgentCore.Application.Hooks.Notices.TurnRefused? other) -> bool +AgentCore.Application.Hooks.Notices.TurnRefused.Reason.get -> AgentCore.Application.Hooks.Notices.TurnRefusal +AgentCore.Application.Hooks.Notices.TurnRefused.Reason.init -> void +AgentCore.Application.Hooks.Notices.TurnRefused.TurnRefused(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.TurnRefusal Reason, bool AfterEnd) -> void +AgentCore.Application.Hooks.Notices.TurnStarted +AgentCore.Application.Hooks.Notices.TurnStarted.AgentId.get -> string! +AgentCore.Application.Hooks.Notices.TurnStarted.AgentId.init -> void +AgentCore.Application.Hooks.Notices.TurnStarted.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! AgentId, out string! UserText) -> void +AgentCore.Application.Hooks.Notices.TurnStarted.Equals(AgentCore.Application.Hooks.Notices.TurnStarted? other) -> bool +AgentCore.Application.Hooks.Notices.TurnStarted.TurnStarted(AgentCore.Application.Hooks.HookScope! Scope, string! AgentId, string! UserText) -> void +AgentCore.Application.Hooks.Notices.TurnStarted.UserText.get -> string! +AgentCore.Application.Hooks.Notices.TurnStarted.UserText.init -> void +AgentCore.Application.Hooks.Notices.TurnSuperseded +AgentCore.Application.Hooks.Notices.TurnSuperseded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out int WithdrewFrom, out int WithdrewThrough) -> void +AgentCore.Application.Hooks.Notices.TurnSuperseded.Equals(AgentCore.Application.Hooks.Notices.TurnSuperseded? other) -> bool +AgentCore.Application.Hooks.Notices.TurnSuperseded.TurnSuperseded(AgentCore.Application.Hooks.HookScope! Scope, int WithdrewFrom, int WithdrewThrough) -> void +AgentCore.Application.Hooks.Notices.TurnSuperseded.WithdrewFrom.get -> int +AgentCore.Application.Hooks.Notices.TurnSuperseded.WithdrewFrom.init -> void +AgentCore.Application.Hooks.Notices.TurnSuperseded.WithdrewThrough.get -> int +AgentCore.Application.Hooks.Notices.TurnSuperseded.WithdrewThrough.init -> void +AgentCore.Application.Hooks.Notices.UnloadCause +AgentCore.Application.Hooks.Notices.UnloadCause.Closed = 1 -> AgentCore.Application.Hooks.Notices.UnloadCause +AgentCore.Application.Hooks.Notices.UnloadCause.Idle = 0 -> AgentCore.Application.Hooks.Notices.UnloadCause +AgentCore.Application.Hooks.Notices.VoiceParty +AgentCore.Application.Hooks.Notices.VoiceParty.Agent = 1 -> AgentCore.Application.Hooks.Notices.VoiceParty +AgentCore.Application.Hooks.Notices.VoiceParty.User = 0 -> AgentCore.Application.Hooks.Notices.VoiceParty +AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceState.Away = 3 -> AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceState.Listening = 0 -> AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceState.Speaking = 2 -> AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceState.Thinking = 1 -> AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceStateChanged +AgentCore.Application.Hooks.Notices.VoiceStateChanged.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.VoiceParty Party, out AgentCore.Application.Hooks.Notices.VoiceState Old, out AgentCore.Application.Hooks.Notices.VoiceState New) -> void +AgentCore.Application.Hooks.Notices.VoiceStateChanged.Equals(AgentCore.Application.Hooks.Notices.VoiceStateChanged? other) -> bool +AgentCore.Application.Hooks.Notices.VoiceStateChanged.New.get -> AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceStateChanged.New.init -> void +AgentCore.Application.Hooks.Notices.VoiceStateChanged.Old.get -> AgentCore.Application.Hooks.Notices.VoiceState +AgentCore.Application.Hooks.Notices.VoiceStateChanged.Old.init -> void +AgentCore.Application.Hooks.Notices.VoiceStateChanged.Party.get -> AgentCore.Application.Hooks.Notices.VoiceParty +AgentCore.Application.Hooks.Notices.VoiceStateChanged.Party.init -> void +AgentCore.Application.Hooks.Notices.VoiceStateChanged.VoiceStateChanged(AgentCore.Application.Hooks.HookScope! Scope, AgentCore.Application.Hooks.Notices.VoiceParty Party, AgentCore.Application.Hooks.Notices.VoiceState Old, AgentCore.Application.Hooks.Notices.VoiceState New) -> void +abstract AgentCore.Application.Hooks.HookNotice.$() -> AgentCore.Application.Hooks.HookNotice! +override AgentCore.Application.Hooks.Notices.ApprovalChanged.$() -> AgentCore.Application.Hooks.Notices.ApprovalChanged! +override AgentCore.Application.Hooks.Notices.ApprovalChanged.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ApprovalChanged.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ApprovalChanged.ToString() -> string! +override AgentCore.Application.Hooks.Notices.CallEnd.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.CallEnd.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.CallEnd.ToString() -> string! +override AgentCore.Application.Hooks.Notices.CallStarted.$() -> AgentCore.Application.Hooks.Notices.CallStarted! +override AgentCore.Application.Hooks.Notices.CallStarted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.CallStarted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.CallStarted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.Compacted.$() -> AgentCore.Application.Hooks.Notices.Compacted! +override AgentCore.Application.Hooks.Notices.Compacted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.Compacted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.Compacted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ConversationEnded.$() -> AgentCore.Application.Hooks.Notices.ConversationEnded! +override AgentCore.Application.Hooks.Notices.ConversationEnded.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ConversationEnded.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ConversationEnded.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ConversationStarted.$() -> AgentCore.Application.Hooks.Notices.ConversationStarted! +override AgentCore.Application.Hooks.Notices.ConversationStarted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ConversationStarted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ConversationStarted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ConversationUnloaded.$() -> AgentCore.Application.Hooks.Notices.ConversationUnloaded! +override AgentCore.Application.Hooks.Notices.ConversationUnloaded.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ConversationUnloaded.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ConversationUnloaded.ToString() -> string! +override AgentCore.Application.Hooks.Notices.Fault.$() -> AgentCore.Application.Hooks.Notices.Fault! +override AgentCore.Application.Hooks.Notices.Fault.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.Fault.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.Fault.ToString() -> string! +override AgentCore.Application.Hooks.Notices.FilePublished.$() -> AgentCore.Application.Hooks.Notices.FilePublished! +override AgentCore.Application.Hooks.Notices.FilePublished.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.FilePublished.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.FilePublished.ToString() -> string! +override AgentCore.Application.Hooks.HookNotice.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.HookNotice.GetHashCode() -> int +override AgentCore.Application.Hooks.HookNotice.ToString() -> string! +override AgentCore.Application.Hooks.HookScope.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.HookScope.GetHashCode() -> int +override AgentCore.Application.Hooks.HookScope.ToString() -> string! +override AgentCore.Application.Hooks.Notices.HostStarted.$() -> AgentCore.Application.Hooks.Notices.HostStarted! +override AgentCore.Application.Hooks.Notices.HostStarted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.HostStarted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.HostStarted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.HostStopping.$() -> AgentCore.Application.Hooks.Notices.HostStopping! +override AgentCore.Application.Hooks.Notices.HostStopping.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.HostStopping.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.HostStopping.ToString() -> string! +override AgentCore.Application.Hooks.Notices.InputModerated.$() -> AgentCore.Application.Hooks.Notices.InputModerated! +override AgentCore.Application.Hooks.Notices.InputModerated.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.InputModerated.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.InputModerated.ToString() -> string! +override AgentCore.Application.Hooks.Notices.KnowledgeSearched.$() -> AgentCore.Application.Hooks.Notices.KnowledgeSearched! +override AgentCore.Application.Hooks.Notices.KnowledgeSearched.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.KnowledgeSearched.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.KnowledgeSearched.ToString() -> string! +override AgentCore.Application.Hooks.Notices.LineSpoken.$() -> AgentCore.Application.Hooks.Notices.LineSpoken! +override AgentCore.Application.Hooks.Notices.LineSpoken.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.LineSpoken.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.LineSpoken.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ModelCalled.$() -> AgentCore.Application.Hooks.Notices.ModelCalled! +override AgentCore.Application.Hooks.Notices.ModelCalled.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ModelCalled.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ModelCalled.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ReplyCut.$() -> AgentCore.Application.Hooks.Notices.ReplyCut! +override AgentCore.Application.Hooks.Notices.ReplyCut.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ReplyCut.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ReplyCut.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ReplyUpdated.$() -> AgentCore.Application.Hooks.Notices.ReplyUpdated! +override AgentCore.Application.Hooks.Notices.ReplyUpdated.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ReplyUpdated.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ReplyUpdated.ToString() -> string! +override AgentCore.Application.Hooks.Notices.RetentionSwept.$() -> AgentCore.Application.Hooks.Notices.RetentionSwept! +override AgentCore.Application.Hooks.Notices.RetentionSwept.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.RetentionSwept.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.RetentionSwept.ToString() -> string! +override AgentCore.Application.Hooks.Notices.SkillLoaded.$() -> AgentCore.Application.Hooks.Notices.SkillLoaded! +override AgentCore.Application.Hooks.Notices.SkillLoaded.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.SkillLoaded.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.SkillLoaded.ToString() -> string! +override AgentCore.Application.Hooks.Notices.StageChanged.$() -> AgentCore.Application.Hooks.Notices.StageChanged! +override AgentCore.Application.Hooks.Notices.StageChanged.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.StageChanged.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.StageChanged.ToString() -> string! +override AgentCore.Application.Hooks.Notices.SubagentEnded.$() -> AgentCore.Application.Hooks.Notices.SubagentEnded! +override AgentCore.Application.Hooks.Notices.SubagentEnded.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.SubagentEnded.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.SubagentEnded.ToString() -> string! +override AgentCore.Application.Hooks.Notices.SubagentStarted.$() -> AgentCore.Application.Hooks.Notices.SubagentStarted! +override AgentCore.Application.Hooks.Notices.SubagentStarted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.SubagentStarted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.SubagentStarted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.ToolCalled.$() -> AgentCore.Application.Hooks.Notices.ToolCalled! +override AgentCore.Application.Hooks.Notices.ToolCalled.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.ToolCalled.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.ToolCalled.ToString() -> string! +override AgentCore.Application.Hooks.Notices.TurnCompleted.$() -> AgentCore.Application.Hooks.Notices.TurnCompleted! +override AgentCore.Application.Hooks.Notices.TurnCompleted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.TurnCompleted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.TurnCompleted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.TurnLatency.$() -> AgentCore.Application.Hooks.Notices.TurnLatency! +override AgentCore.Application.Hooks.Notices.TurnLatency.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.TurnLatency.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.TurnLatency.ToString() -> string! +override AgentCore.Application.Hooks.Notices.TurnRefused.$() -> AgentCore.Application.Hooks.Notices.TurnRefused! +override AgentCore.Application.Hooks.Notices.TurnRefused.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.TurnRefused.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.TurnRefused.ToString() -> string! +override AgentCore.Application.Hooks.Notices.TurnStarted.$() -> AgentCore.Application.Hooks.Notices.TurnStarted! +override AgentCore.Application.Hooks.Notices.TurnStarted.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.TurnStarted.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.TurnStarted.ToString() -> string! +override AgentCore.Application.Hooks.Notices.TurnSuperseded.$() -> AgentCore.Application.Hooks.Notices.TurnSuperseded! +override AgentCore.Application.Hooks.Notices.TurnSuperseded.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.TurnSuperseded.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.TurnSuperseded.ToString() -> string! +override AgentCore.Application.Hooks.Notices.VoiceStateChanged.$() -> AgentCore.Application.Hooks.Notices.VoiceStateChanged! +override AgentCore.Application.Hooks.Notices.VoiceStateChanged.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.VoiceStateChanged.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.VoiceStateChanged.ToString() -> string! +override sealed AgentCore.Application.Hooks.Notices.ApprovalChanged.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.CallStarted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.Compacted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ConversationEnded.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ConversationStarted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ConversationUnloaded.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.Fault.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.FilePublished.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.HostStarted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.HostStopping.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.InputModerated.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.KnowledgeSearched.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.LineSpoken.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ModelCalled.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ReplyCut.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ReplyUpdated.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.RetentionSwept.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.SkillLoaded.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.StageChanged.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.SubagentEnded.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.SubagentStarted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.ToolCalled.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.TurnCompleted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.TurnLatency.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.TurnRefused.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.TurnStarted.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.TurnSuperseded.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +override sealed AgentCore.Application.Hooks.Notices.VoiceStateChanged.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +static AgentCore.Application.Hooks.Notices.ApprovalChanged.operator !=(AgentCore.Application.Hooks.Notices.ApprovalChanged? left, AgentCore.Application.Hooks.Notices.ApprovalChanged? right) -> bool +static AgentCore.Application.Hooks.Notices.ApprovalChanged.operator ==(AgentCore.Application.Hooks.Notices.ApprovalChanged? left, AgentCore.Application.Hooks.Notices.ApprovalChanged? right) -> bool +static AgentCore.Application.Hooks.Notices.CallEnd.operator !=(AgentCore.Application.Hooks.Notices.CallEnd? left, AgentCore.Application.Hooks.Notices.CallEnd? right) -> bool +static AgentCore.Application.Hooks.Notices.CallEnd.operator ==(AgentCore.Application.Hooks.Notices.CallEnd? left, AgentCore.Application.Hooks.Notices.CallEnd? right) -> bool +static AgentCore.Application.Hooks.Notices.CallStarted.operator !=(AgentCore.Application.Hooks.Notices.CallStarted? left, AgentCore.Application.Hooks.Notices.CallStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.CallStarted.operator ==(AgentCore.Application.Hooks.Notices.CallStarted? left, AgentCore.Application.Hooks.Notices.CallStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.Compacted.operator !=(AgentCore.Application.Hooks.Notices.Compacted? left, AgentCore.Application.Hooks.Notices.Compacted? right) -> bool +static AgentCore.Application.Hooks.Notices.Compacted.operator ==(AgentCore.Application.Hooks.Notices.Compacted? left, AgentCore.Application.Hooks.Notices.Compacted? right) -> bool +static AgentCore.Application.Hooks.Notices.ConversationEnded.operator !=(AgentCore.Application.Hooks.Notices.ConversationEnded? left, AgentCore.Application.Hooks.Notices.ConversationEnded? right) -> bool +static AgentCore.Application.Hooks.Notices.ConversationEnded.operator ==(AgentCore.Application.Hooks.Notices.ConversationEnded? left, AgentCore.Application.Hooks.Notices.ConversationEnded? right) -> bool +static AgentCore.Application.Hooks.Notices.ConversationStarted.operator !=(AgentCore.Application.Hooks.Notices.ConversationStarted? left, AgentCore.Application.Hooks.Notices.ConversationStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.ConversationStarted.operator ==(AgentCore.Application.Hooks.Notices.ConversationStarted? left, AgentCore.Application.Hooks.Notices.ConversationStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.ConversationUnloaded.operator !=(AgentCore.Application.Hooks.Notices.ConversationUnloaded? left, AgentCore.Application.Hooks.Notices.ConversationUnloaded? right) -> bool +static AgentCore.Application.Hooks.Notices.ConversationUnloaded.operator ==(AgentCore.Application.Hooks.Notices.ConversationUnloaded? left, AgentCore.Application.Hooks.Notices.ConversationUnloaded? right) -> bool +static AgentCore.Application.Hooks.Notices.Fault.operator !=(AgentCore.Application.Hooks.Notices.Fault? left, AgentCore.Application.Hooks.Notices.Fault? right) -> bool +static AgentCore.Application.Hooks.Notices.Fault.operator ==(AgentCore.Application.Hooks.Notices.Fault? left, AgentCore.Application.Hooks.Notices.Fault? right) -> bool +static AgentCore.Application.Hooks.Notices.FilePublished.operator !=(AgentCore.Application.Hooks.Notices.FilePublished? left, AgentCore.Application.Hooks.Notices.FilePublished? right) -> bool +static AgentCore.Application.Hooks.Notices.FilePublished.operator ==(AgentCore.Application.Hooks.Notices.FilePublished? left, AgentCore.Application.Hooks.Notices.FilePublished? right) -> bool +static AgentCore.Application.Hooks.HookNotice.operator !=(AgentCore.Application.Hooks.HookNotice? left, AgentCore.Application.Hooks.HookNotice? right) -> bool +static AgentCore.Application.Hooks.HookNotice.operator ==(AgentCore.Application.Hooks.HookNotice? left, AgentCore.Application.Hooks.HookNotice? right) -> bool +static AgentCore.Application.Hooks.HookScope.operator !=(AgentCore.Application.Hooks.HookScope? left, AgentCore.Application.Hooks.HookScope? right) -> bool +static AgentCore.Application.Hooks.HookScope.operator ==(AgentCore.Application.Hooks.HookScope? left, AgentCore.Application.Hooks.HookScope? right) -> bool +static AgentCore.Application.Hooks.Notices.HostStarted.operator !=(AgentCore.Application.Hooks.Notices.HostStarted? left, AgentCore.Application.Hooks.Notices.HostStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.HostStarted.operator ==(AgentCore.Application.Hooks.Notices.HostStarted? left, AgentCore.Application.Hooks.Notices.HostStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.HostStopping.operator !=(AgentCore.Application.Hooks.Notices.HostStopping? left, AgentCore.Application.Hooks.Notices.HostStopping? right) -> bool +static AgentCore.Application.Hooks.Notices.HostStopping.operator ==(AgentCore.Application.Hooks.Notices.HostStopping? left, AgentCore.Application.Hooks.Notices.HostStopping? right) -> bool +static AgentCore.Application.Hooks.Notices.InputModerated.operator !=(AgentCore.Application.Hooks.Notices.InputModerated? left, AgentCore.Application.Hooks.Notices.InputModerated? right) -> bool +static AgentCore.Application.Hooks.Notices.InputModerated.operator ==(AgentCore.Application.Hooks.Notices.InputModerated? left, AgentCore.Application.Hooks.Notices.InputModerated? right) -> bool +static AgentCore.Application.Hooks.Notices.KnowledgeSearched.operator !=(AgentCore.Application.Hooks.Notices.KnowledgeSearched? left, AgentCore.Application.Hooks.Notices.KnowledgeSearched? right) -> bool +static AgentCore.Application.Hooks.Notices.KnowledgeSearched.operator ==(AgentCore.Application.Hooks.Notices.KnowledgeSearched? left, AgentCore.Application.Hooks.Notices.KnowledgeSearched? right) -> bool +static AgentCore.Application.Hooks.Notices.LineSpoken.operator !=(AgentCore.Application.Hooks.Notices.LineSpoken? left, AgentCore.Application.Hooks.Notices.LineSpoken? right) -> bool +static AgentCore.Application.Hooks.Notices.LineSpoken.operator ==(AgentCore.Application.Hooks.Notices.LineSpoken? left, AgentCore.Application.Hooks.Notices.LineSpoken? right) -> bool +static AgentCore.Application.Hooks.Notices.ModelCalled.operator !=(AgentCore.Application.Hooks.Notices.ModelCalled? left, AgentCore.Application.Hooks.Notices.ModelCalled? right) -> bool +static AgentCore.Application.Hooks.Notices.ModelCalled.operator ==(AgentCore.Application.Hooks.Notices.ModelCalled? left, AgentCore.Application.Hooks.Notices.ModelCalled? right) -> bool +static AgentCore.Application.Hooks.Notices.ReplyCut.operator !=(AgentCore.Application.Hooks.Notices.ReplyCut? left, AgentCore.Application.Hooks.Notices.ReplyCut? right) -> bool +static AgentCore.Application.Hooks.Notices.ReplyCut.operator ==(AgentCore.Application.Hooks.Notices.ReplyCut? left, AgentCore.Application.Hooks.Notices.ReplyCut? right) -> bool +static AgentCore.Application.Hooks.Notices.ReplyUpdated.operator !=(AgentCore.Application.Hooks.Notices.ReplyUpdated? left, AgentCore.Application.Hooks.Notices.ReplyUpdated? right) -> bool +static AgentCore.Application.Hooks.Notices.ReplyUpdated.operator ==(AgentCore.Application.Hooks.Notices.ReplyUpdated? left, AgentCore.Application.Hooks.Notices.ReplyUpdated? right) -> bool +static AgentCore.Application.Hooks.Notices.RetentionSwept.operator !=(AgentCore.Application.Hooks.Notices.RetentionSwept? left, AgentCore.Application.Hooks.Notices.RetentionSwept? right) -> bool +static AgentCore.Application.Hooks.Notices.RetentionSwept.operator ==(AgentCore.Application.Hooks.Notices.RetentionSwept? left, AgentCore.Application.Hooks.Notices.RetentionSwept? right) -> bool +static AgentCore.Application.Hooks.Notices.SkillLoaded.operator !=(AgentCore.Application.Hooks.Notices.SkillLoaded? left, AgentCore.Application.Hooks.Notices.SkillLoaded? right) -> bool +static AgentCore.Application.Hooks.Notices.SkillLoaded.operator ==(AgentCore.Application.Hooks.Notices.SkillLoaded? left, AgentCore.Application.Hooks.Notices.SkillLoaded? right) -> bool +static AgentCore.Application.Hooks.Notices.StageChanged.operator !=(AgentCore.Application.Hooks.Notices.StageChanged? left, AgentCore.Application.Hooks.Notices.StageChanged? right) -> bool +static AgentCore.Application.Hooks.Notices.StageChanged.operator ==(AgentCore.Application.Hooks.Notices.StageChanged? left, AgentCore.Application.Hooks.Notices.StageChanged? right) -> bool +static AgentCore.Application.Hooks.Notices.SubagentEnded.operator !=(AgentCore.Application.Hooks.Notices.SubagentEnded? left, AgentCore.Application.Hooks.Notices.SubagentEnded? right) -> bool +static AgentCore.Application.Hooks.Notices.SubagentEnded.operator ==(AgentCore.Application.Hooks.Notices.SubagentEnded? left, AgentCore.Application.Hooks.Notices.SubagentEnded? right) -> bool +static AgentCore.Application.Hooks.Notices.SubagentStarted.operator !=(AgentCore.Application.Hooks.Notices.SubagentStarted? left, AgentCore.Application.Hooks.Notices.SubagentStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.SubagentStarted.operator ==(AgentCore.Application.Hooks.Notices.SubagentStarted? left, AgentCore.Application.Hooks.Notices.SubagentStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.ToolCalled.operator !=(AgentCore.Application.Hooks.Notices.ToolCalled? left, AgentCore.Application.Hooks.Notices.ToolCalled? right) -> bool +static AgentCore.Application.Hooks.Notices.ToolCalled.operator ==(AgentCore.Application.Hooks.Notices.ToolCalled? left, AgentCore.Application.Hooks.Notices.ToolCalled? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnCompleted.operator !=(AgentCore.Application.Hooks.Notices.TurnCompleted? left, AgentCore.Application.Hooks.Notices.TurnCompleted? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnCompleted.operator ==(AgentCore.Application.Hooks.Notices.TurnCompleted? left, AgentCore.Application.Hooks.Notices.TurnCompleted? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnLatency.operator !=(AgentCore.Application.Hooks.Notices.TurnLatency? left, AgentCore.Application.Hooks.Notices.TurnLatency? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnLatency.operator ==(AgentCore.Application.Hooks.Notices.TurnLatency? left, AgentCore.Application.Hooks.Notices.TurnLatency? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnRefusalTokens.ToToken(AgentCore.Application.Hooks.Notices.TurnRefusal refusal) -> string! +static AgentCore.Application.Hooks.Notices.TurnRefused.operator !=(AgentCore.Application.Hooks.Notices.TurnRefused? left, AgentCore.Application.Hooks.Notices.TurnRefused? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnRefused.operator ==(AgentCore.Application.Hooks.Notices.TurnRefused? left, AgentCore.Application.Hooks.Notices.TurnRefused? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnStarted.operator !=(AgentCore.Application.Hooks.Notices.TurnStarted? left, AgentCore.Application.Hooks.Notices.TurnStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnStarted.operator ==(AgentCore.Application.Hooks.Notices.TurnStarted? left, AgentCore.Application.Hooks.Notices.TurnStarted? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnSuperseded.operator !=(AgentCore.Application.Hooks.Notices.TurnSuperseded? left, AgentCore.Application.Hooks.Notices.TurnSuperseded? right) -> bool +static AgentCore.Application.Hooks.Notices.TurnSuperseded.operator ==(AgentCore.Application.Hooks.Notices.TurnSuperseded? left, AgentCore.Application.Hooks.Notices.TurnSuperseded? right) -> bool +static AgentCore.Application.Hooks.Notices.VoiceStateChanged.operator !=(AgentCore.Application.Hooks.Notices.VoiceStateChanged? left, AgentCore.Application.Hooks.Notices.VoiceStateChanged? right) -> bool +static AgentCore.Application.Hooks.Notices.VoiceStateChanged.operator ==(AgentCore.Application.Hooks.Notices.VoiceStateChanged? left, AgentCore.Application.Hooks.Notices.VoiceStateChanged? right) -> bool +virtual AgentCore.Application.Hooks.HookNotice.EqualityContract.get -> System.Type! +virtual AgentCore.Application.Hooks.HookNotice.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +virtual AgentCore.Application.Hooks.HookNotice.PrintMembers(System.Text.StringBuilder! builder) -> bool +AgentCore.Application.Hooks.AgentHook +AgentCore.Application.Hooks.AgentHook.AgentHook() -> void +AgentCore.Application.Hooks.Gates.ApprovalGate +AgentCore.Application.Hooks.Gates.ApprovalGate.Approve() -> void +AgentCore.Application.Hooks.Gates.ApprovalGate.Arguments.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Hooks.Gates.ApprovalGate.CallId.get -> string! +AgentCore.Application.Hooks.Gates.ApprovalGate.Deny(string! reason) -> void +AgentCore.Application.Hooks.Gates.ApprovalGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ApprovalGate.ToolName.get -> string! +AgentCore.Application.Hooks.Gates.CallGate +AgentCore.Application.Hooks.Gates.CallGate.Accept(string! conversationId, string? brief = null) -> void +AgentCore.Application.Hooks.Gates.CallGate.CallId.get -> string! +AgentCore.Application.Hooks.Gates.CallGate.Entry.get -> string! +AgentCore.Application.Hooks.Gates.CallGate.From.get -> string? +AgentCore.Application.Hooks.Gates.CallGate.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Hooks.Gates.CallGate.Reject(AgentCore.Application.Hooks.Gates.CallRefusal reason) -> void +AgentCore.Application.Hooks.Gates.CallGate.To.get -> string? +AgentCore.Application.Hooks.Gates.CallGate.Transport.get -> string! +AgentCore.Application.Hooks.Gates.CompactionGate +AgentCore.Application.Hooks.Gates.CompactionGate.Cancel() -> void +AgentCore.Application.Hooks.Gates.CompactionGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.CompactionGate.MessageCount.get -> int +AgentCore.Application.Hooks.Gates.CompactionGate.ReplaceSummary(string! text) -> void +AgentCore.Application.Hooks.Gates.EntryGate +AgentCore.Application.Hooks.Gates.EntryGate.Choose(string! entry) -> void +AgentCore.Application.Hooks.Gates.EntryGate.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Hooks.Gates.EntryGate.Refuse() -> void +AgentCore.Application.Hooks.Gates.EntryGate.RequestedEntry.get -> string? +AgentCore.Application.Hooks.Gates.EntryGate.Route.get -> string! +AgentCore.Application.Hooks.Gates.EntryGate.Transport.get -> AgentCore.Application.Hooks.Gates.EntryTransport +AgentCore.Application.Hooks.Gates.EntryGate.User.get -> System.Security.Claims.ClaimsPrincipal? +AgentCore.Application.Hooks.GatePoint +AgentCore.Application.Hooks.GatePoint.Deadline.get -> System.TimeSpan +AgentCore.Application.Hooks.GatePoint.DefaultFailure.get -> AgentCore.Application.Hooks.HookFailure +AgentCore.Application.Hooks.GatePoint.Name.get -> string! +AgentCore.Application.Hooks.HookFailure +AgentCore.Application.Hooks.HookFailure.Closed = 1 -> AgentCore.Application.Hooks.HookFailure +AgentCore.Application.Hooks.HookFailure.Open = 0 -> AgentCore.Application.Hooks.HookFailure +AgentCore.Application.Hooks.HookGate +AgentCore.Application.Hooks.HookGate.Scope.get -> AgentCore.Application.Hooks.HookScope! +AgentCore.Application.Hooks.Gates.ModelFailureGate +AgentCore.Application.Hooks.Gates.ModelFailureGate.Attempt.get -> int +AgentCore.Application.Hooks.Gates.ModelFailureGate.CanRetry.get -> bool +AgentCore.Application.Hooks.Gates.ModelFailureGate.Exception.get -> System.Exception! +AgentCore.Application.Hooks.Gates.ModelFailureGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ModelFailureGate.Respond(Microsoft.Extensions.AI.ChatResponse! response) -> void +AgentCore.Application.Hooks.Gates.ModelFailureGate.Retry() -> void +AgentCore.Application.Hooks.Gates.ModelFailureGate.Round.get -> int +AgentCore.Application.Hooks.Gates.ModelGate +AgentCore.Application.Hooks.Gates.ModelGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ModelGate.Messages.get -> System.Collections.Generic.IReadOnlyList! +AgentCore.Application.Hooks.Gates.ModelGate.Options.get -> Microsoft.Extensions.AI.ChatOptions? +AgentCore.Application.Hooks.Gates.ModelGate.ReplaceMessages(System.Collections.Generic.IEnumerable! messages) -> void +AgentCore.Application.Hooks.Gates.ModelGate.Respond(Microsoft.Extensions.AI.ChatResponse! response) -> void +AgentCore.Application.Hooks.Gates.ModelGate.Round.get -> int +AgentCore.Application.Hooks.Gates.ModelGate.SetOptions(Microsoft.Extensions.AI.ChatOptions! options) -> void +AgentCore.Application.Hooks.Gates.ModelResultGate +AgentCore.Application.Hooks.Gates.ModelResultGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ModelResultGate.ReplaceResponse(Microsoft.Extensions.AI.ChatResponse! response) -> void +AgentCore.Application.Hooks.Gates.ModelResultGate.Response.get -> Microsoft.Extensions.AI.ChatResponse! +AgentCore.Application.Hooks.Gates.ModelResultGate.Round.get -> int +AgentCore.Application.Hooks.Gates.RunEndGate +AgentCore.Application.Hooks.Gates.RunEndGate.AgentId.get -> string? +AgentCore.Application.Hooks.Gates.RunEndGate.Continue(string! message) -> void +AgentCore.Application.Hooks.Gates.RunEndGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.RunEndGate.Iteration.get -> int +AgentCore.Application.Hooks.Gates.RunEndGate.ReplyText.get -> string! +AgentCore.Application.Hooks.Gates.RunGate +AgentCore.Application.Hooks.Gates.RunGate.AddInstructions(string! text) -> void +AgentCore.Application.Hooks.Gates.RunGate.AddMessages(System.Collections.Generic.IEnumerable! messages) -> void +AgentCore.Application.Hooks.Gates.RunGate.AddTools(System.Collections.Generic.IEnumerable! tools) -> void +AgentCore.Application.Hooks.Gates.RunGate.AgentId.get -> string! +AgentCore.Application.Hooks.Gates.RunGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.RunGate.Nested.get -> bool +AgentCore.Application.Hooks.Gates.ToolFailureGate +AgentCore.Application.Hooks.Gates.ToolFailureGate.Attempt.get -> int +AgentCore.Application.Hooks.Gates.ToolFailureGate.CallId.get -> string! +AgentCore.Application.Hooks.Gates.ToolFailureGate.CanRetry.get -> bool +AgentCore.Application.Hooks.Gates.ToolFailureGate.Exception.get -> System.Exception! +AgentCore.Application.Hooks.Gates.ToolFailureGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ToolFailureGate.Respond(object? result) -> void +AgentCore.Application.Hooks.Gates.ToolFailureGate.Retry() -> void +AgentCore.Application.Hooks.Gates.ToolFailureGate.ToolName.get -> string! +AgentCore.Application.Hooks.Gates.ToolGate +AgentCore.Application.Hooks.Gates.ToolGate.Arguments.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Hooks.Gates.ToolGate.Block(object? resultForModel) -> void +AgentCore.Application.Hooks.Gates.ToolGate.CallId.get -> string! +AgentCore.Application.Hooks.Gates.ToolGate.EndLoop() -> void +AgentCore.Application.Hooks.Gates.ToolGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ToolGate.ReplaceArguments(System.Collections.Generic.IReadOnlyDictionary! arguments) -> void +AgentCore.Application.Hooks.Gates.ToolGate.Respond(object? result) -> void +AgentCore.Application.Hooks.Gates.ToolGate.ToolName.get -> string! +AgentCore.Application.Hooks.Gates.ToolResultGate +AgentCore.Application.Hooks.Gates.ToolResultGate.CallId.get -> string! +AgentCore.Application.Hooks.Gates.ToolResultGate.EndLoop() -> void +AgentCore.Application.Hooks.Gates.ToolResultGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.ToolResultGate.ReplaceResult(object? result) -> void +AgentCore.Application.Hooks.Gates.ToolResultGate.Result.get -> object? +AgentCore.Application.Hooks.Gates.ToolResultGate.ToolName.get -> string! +AgentCore.Application.Hooks.Gates.TurnGate +AgentCore.Application.Hooks.Gates.TurnGate.AddContext(string! text) -> void +AgentCore.Application.Hooks.Gates.TurnGate.Block(string! reply) -> void +AgentCore.Application.Hooks.Gates.TurnGate.Items.get -> System.Collections.Generic.IDictionary! +AgentCore.Application.Hooks.Gates.TurnGate.ReplaceInput(string! text) -> void +AgentCore.Application.Hooks.Gates.TurnGate.UserText.get -> string! +override AgentCore.Application.Hooks.GatePoint.ToString() -> string! +static AgentCore.Application.Hooks.GatePoint.AfterModelFailed.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.AfterModel.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.AfterRun.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.AfterToolFailed.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.AfterTool.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.All.get -> System.Collections.Generic.IReadOnlyList! +static AgentCore.Application.Hooks.GatePoint.BeforeCall.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeCompaction.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeEntry.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeModel.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeRun.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeToolApproval.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeTool.get -> AgentCore.Application.Hooks.GatePoint! +static AgentCore.Application.Hooks.GatePoint.BeforeTurn.get -> AgentCore.Application.Hooks.GatePoint! +virtual AgentCore.Application.Hooks.AgentHook.AfterModelAsync(AgentCore.Application.Hooks.Gates.ModelResultGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.AfterModelFailedAsync(AgentCore.Application.Hooks.Gates.ModelFailureGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.AfterRunAsync(AgentCore.Application.Hooks.Gates.RunEndGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.AfterToolAsync(AgentCore.Application.Hooks.Gates.ToolResultGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.AfterToolFailedAsync(AgentCore.Application.Hooks.Gates.ToolFailureGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeCallAsync(AgentCore.Application.Hooks.Gates.CallGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeCompactionAsync(AgentCore.Application.Hooks.Gates.CompactionGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeEntryAsync(AgentCore.Application.Hooks.Gates.EntryGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeModelAsync(AgentCore.Application.Hooks.Gates.ModelGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeRunAsync(AgentCore.Application.Hooks.Gates.RunGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeToolApprovalAsync(AgentCore.Application.Hooks.Gates.ApprovalGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeToolAsync(AgentCore.Application.Hooks.Gates.ToolGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.BeforeTurnAsync(AgentCore.Application.Hooks.Gates.TurnGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.FailureFor(AgentCore.Application.Hooks.GatePoint! point) -> AgentCore.Application.Hooks.HookFailure +virtual AgentCore.Application.Hooks.AgentHook.NoticeTimeout.get -> System.TimeSpan? +virtual AgentCore.Application.Hooks.AgentHook.OnApprovalChangedAsync(AgentCore.Application.Hooks.Notices.ApprovalChanged! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnCallStartedAsync(AgentCore.Application.Hooks.Notices.CallStarted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnCompactedAsync(AgentCore.Application.Hooks.Notices.Compacted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnConversationEndedAsync(AgentCore.Application.Hooks.Notices.ConversationEnded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnConversationStartedAsync(AgentCore.Application.Hooks.Notices.ConversationStarted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnConversationUnloadedAsync(AgentCore.Application.Hooks.Notices.ConversationUnloaded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnFaultAsync(AgentCore.Application.Hooks.Notices.Fault! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnFilePublishedAsync(AgentCore.Application.Hooks.Notices.FilePublished! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnHostStartedAsync(AgentCore.Application.Hooks.Notices.HostStarted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnHostStoppingAsync(AgentCore.Application.Hooks.Notices.HostStopping! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnInputModeratedAsync(AgentCore.Application.Hooks.Notices.InputModerated! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnKnowledgeSearchedAsync(AgentCore.Application.Hooks.Notices.KnowledgeSearched! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnLineSpokenAsync(AgentCore.Application.Hooks.Notices.LineSpoken! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnModelCalledAsync(AgentCore.Application.Hooks.Notices.ModelCalled! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnReplyCutAsync(AgentCore.Application.Hooks.Notices.ReplyCut! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnReplyUpdatedAsync(AgentCore.Application.Hooks.Notices.ReplyUpdated! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnRetentionSweptAsync(AgentCore.Application.Hooks.Notices.RetentionSwept! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnSkillLoadedAsync(AgentCore.Application.Hooks.Notices.SkillLoaded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnStageChangedAsync(AgentCore.Application.Hooks.Notices.StageChanged! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnSubagentEndedAsync(AgentCore.Application.Hooks.Notices.SubagentEnded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnSubagentStartedAsync(AgentCore.Application.Hooks.Notices.SubagentStarted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnToolCalledAsync(AgentCore.Application.Hooks.Notices.ToolCalled! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnTurnCompletedAsync(AgentCore.Application.Hooks.Notices.TurnCompleted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnTurnLatencyAsync(AgentCore.Application.Hooks.Notices.TurnLatency! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnTurnRefusedAsync(AgentCore.Application.Hooks.Notices.TurnRefused! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnTurnStartedAsync(AgentCore.Application.Hooks.Notices.TurnStarted! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnTurnSupersededAsync(AgentCore.Application.Hooks.Notices.TurnSuperseded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +virtual AgentCore.Application.Hooks.AgentHook.OnVoiceStateChangedAsync(AgentCore.Application.Hooks.Notices.VoiceStateChanged! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Hooks.BuiltInHooks +static AgentCore.Application.Hooks.BuiltInHooks.Create(AgentCore.Application.Ports.IAuditSinkPort! auditSink, Microsoft.Extensions.Logging.ILogger? logger = null) -> System.Collections.Generic.IReadOnlyList! +AgentCore.Application.Hooks.Notices.ConversationOrigin.Unopened = 3 -> AgentCore.Application.Hooks.Notices.ConversationOrigin +AgentCore.Application.Hooks.Notices.CallEnded +AgentCore.Application.Hooks.Notices.CallEnded.CallEnded(AgentCore.Application.Hooks.HookScope! Scope, string! CallId, double Seconds, string? Cause, AgentCore.Application.Hooks.Notices.CallEndReason Reason) -> void +AgentCore.Application.Hooks.Notices.CallEnded.CallId.get -> string! +AgentCore.Application.Hooks.Notices.CallEnded.CallId.init -> void +AgentCore.Application.Hooks.Notices.CallEnded.Cause.get -> string? +AgentCore.Application.Hooks.Notices.CallEnded.Cause.init -> void +AgentCore.Application.Hooks.Notices.CallEnded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out string! CallId, out double Seconds, out string? Cause, out AgentCore.Application.Hooks.Notices.CallEndReason Reason) -> void +AgentCore.Application.Hooks.Notices.CallEnded.Equals(AgentCore.Application.Hooks.Notices.CallEnded? other) -> bool +AgentCore.Application.Hooks.Notices.CallEnded.Reason.get -> AgentCore.Application.Hooks.Notices.CallEndReason +AgentCore.Application.Hooks.Notices.CallEnded.Reason.init -> void +AgentCore.Application.Hooks.Notices.CallEnded.Seconds.get -> double +AgentCore.Application.Hooks.Notices.CallEnded.Seconds.init -> void +AgentCore.Application.Hooks.Notices.CallEndReason +AgentCore.Application.Hooks.Notices.CallEndReason.Closed = 3 -> AgentCore.Application.Hooks.Notices.CallEndReason +AgentCore.Application.Hooks.Notices.CallEndReason.Ended = 0 -> AgentCore.Application.Hooks.Notices.CallEndReason +AgentCore.Application.Hooks.Notices.CallEndReason.Lost = 2 -> AgentCore.Application.Hooks.Notices.CallEndReason +AgentCore.Application.Hooks.Notices.CallEndReason.Replaced = 1 -> AgentCore.Application.Hooks.Notices.CallEndReason +override AgentCore.Application.Hooks.Notices.CallEnded.$() -> AgentCore.Application.Hooks.Notices.CallEnded! +override AgentCore.Application.Hooks.Notices.CallEnded.Equals(object? obj) -> bool +override AgentCore.Application.Hooks.Notices.CallEnded.GetHashCode() -> int +override AgentCore.Application.Hooks.Notices.CallEnded.ToString() -> string! +override sealed AgentCore.Application.Hooks.Notices.CallEnded.Equals(AgentCore.Application.Hooks.HookNotice? other) -> bool +static AgentCore.Application.Hooks.Notices.CallEnded.operator !=(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool +static AgentCore.Application.Hooks.Notices.CallEnded.operator ==(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool +virtual AgentCore.Application.Hooks.AgentHook.OnCallEndedAsync(AgentCore.Application.Hooks.Notices.CallEnded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask diff --git a/src/AgentCore.Application/Runtime/AgentCoreAgent.cs b/src/AgentCore.Application/Runtime/AgentCoreAgent.cs index 77519b8a..65363d69 100644 --- a/src/AgentCore.Application/Runtime/AgentCoreAgent.cs +++ b/src/AgentCore.Application/Runtime/AgentCoreAgent.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Runtime { @@ -79,14 +80,14 @@ protected override async ValueTask SerializeSessionCoreAsync( ArgumentNullException.ThrowIfNull(session); ConversationSession conversation = ResolveOwn(session).Conversation; - // A session this entry no longer holds live was unloaded: store 0's own copy of its state + // A session this entry no longer holds live was unloaded: the conversation store's own copy of its state // outranks whatever this blob would otherwise carry, so the state travels as null. ConversationSession? live = await _sessions .TryGetAsync(EntryName, conversation.ConversationId, cancellationToken) .ConfigureAwait(false); return JsonSerializer.SerializeToElement( - new SerializedSession(conversation.ConversationId, live?.Snapshot(), EntryName), + new SerializedSession(conversation.ConversationId, live?.States.Snapshot(), EntryName), jsonSerializerOptions ?? ConversationStateJson.Options); } @@ -104,7 +105,7 @@ protected override async ValueTask DeserializeSessionCoreAsync( throw new ArgumentException( "The serialized session names no conversation and holds no state, so it is not one this " + "agent wrote. It expects { conversationId, state } — the conversation's id beside its state. A " - + "bare ConversationSessionState, the value store 0 keeps in conversation.state, is the other shape " + + "bare ConversationSessionState, the value the conversation store keeps in conversation.state, is the other shape " + "and reading it as this one would lose the conversation's transcript.", nameof(serializedState)); } @@ -268,7 +269,7 @@ private static ChatMessage UserMessage(IEnumerable messages) } /// One serialized session: the conversation it is, the entry that wrote it, and the state it held. - /// The id of the conversation. Store 1 is keyed by it, so it is the half that finds the words. + /// The id of the conversation. The message store is keyed by it, so it is the half that finds the words. /// What the session alone held, or when the blob named none. /// /// The entry that wrote the blob. Written on every serialize and never read back: any entry may resume a diff --git a/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs similarity index 75% rename from src/AgentCore.Application/Runtime/ConversationTurnRunner.cs rename to src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs index 177f5825..bb4b1e1c 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs +++ b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs @@ -1,13 +1,22 @@ +using System.Collections.Concurrent; using System.Diagnostics; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Knowledge; +using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.State; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents { internal sealed class ConversationTurnRunner { @@ -45,6 +54,24 @@ internal void SetTimeZone(TimeZoneInfo zone) _timeZone = zone; } + /// + /// Reads the clock line the next turn's agent would be told now (), in the + /// same zone: the person's when the host named one, else the clock's own. + /// + /// The line, or when that agent has clock: false. + internal string? ClockLine() + { + AgentsConfiguration agents = _session.Compiled.Configuration.Agents; + string? agentId = _session.Policy is { } policy + ? policy.CurrentAgentId + : _session.Compiled.Configuration.Entries.GetValueOrDefault(_session.Compiled.EntryName)?.Agent; + bool clock = agents.Items.FirstOrDefault(agent => agent.Id == agentId) is { } named + ? AgentHarness.Compose(agents.Defaults, named).Clock + : agents.Defaults?.Clock ?? true; + + return clock ? ClockContextProvider.Describe(_session.Time, _timeZone ?? _session.Time.LocalTimeZone) : null; + } + /// /// Admits one turn: refuses a terminal or disposed conversation, and waits for a running turn of this /// conversation to end, up to counted from . @@ -75,7 +102,7 @@ internal async Task AdmitTurnAsync(long started, CancellationToken cancellationT private void RefuseIfTerminal() { - if (!_session.Ledger.Reads.Diverged || _session.Events.HasEnded) + if (!_session.Ledger.Reads.Diverged || _session.Lifetime.Ending.Requested) { RefuseIfComplete(); } @@ -87,6 +114,7 @@ internal void RefuseIfComplete() { if (_session.IsComplete) { + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusal.Terminal); throw new InvalidOperationException( $"The conversation '{_session.ConversationId}' reached the terminal stage '{_session.Stage}', so it runs no further turn."); } @@ -100,8 +128,9 @@ internal void RefuseIfComplete() /// What the caller said or answered: words, an approval answer, or both. /// The session of this conversation. /// Where the turn hangs, or null for a caller that does not say. + /// What was said on a call ahead of that no turn answered, or . /// Everything the rest of the turn needs. - internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, ConversationTurnOrigin? origin = null) + internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, ConversationTurnOrigin? origin = null, IReadOnlyList? before = null) { Activity? activity = null; try @@ -112,7 +141,12 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, string? reminder = _session.Policy is null ? null : UnfilledSlotReminder.Build(_session.State, _session.Policy.CurrentStage); - ChatMessage spoken = userInput; + // An edit that withdraws the turn that asked leaves its requests unanswered: the edit drops them. + IReadOnlySet withdrawn = TurnApprovalAnswers.Withdrawn(_session.History, session, origin); + ChatMessage spoken = !userInput.Contents.OfType().Any() + && PendingApprovalQueue.Open(session).Where(request => !withdrawn.Contains(request.RequestId)).ToList() is { Count: > 0 } open + ? TurnApprovalAnswers.MovedOn(userInput, open) + : userInput; _session.History.BeginTurn(session, _session.State.TurnIndex); @@ -139,7 +173,9 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, new TurnResults(), new TurnFiles(), knowledge, - origin); + origin, + [.. PendingApprovalQueue.Requests(session).Select(static request => request.ToolCall.CallId)], + before ?? []); } catch { @@ -214,17 +250,24 @@ internal TurnInvocation TurnInvocationOf(ConversationTurn turn, ITurnCompleter c Sources = turn.Sources, Results = turn.Results, Files = turn.Files, - OnToolFailure = failure => _session.Events.RaiseToolFailure(turn.Index, failure), Tools = _delegatedTools?.Tools, ToolsFor = _delegatedTools?.ToolId, State = _session.State, HistorySession = turn.Session, User = turn.Spoken, + Before = turn.Before, Origin = turn.Origin, RendersHistory = !_session.SessionCarriesHistory && !_session.ReusesGraphSession, Completer = completer, CutSlot = slot, + ToolStop = _session.Lifetime.Ending.ToolStop, + ToolRuns = _session.ToolRuns, Notices = new TurnNotices(), + Hooks = _session.Hooks, + RefusalReply = _session.Compiled.RefusalReply, + Items = new ConcurrentDictionary(StringComparer.Ordinal), + Rounds = new TurnRounds(), + AddedContext = new ConcurrentQueue(), }; } } diff --git a/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs b/src/AgentCore.Application/Runtime/Agents/DelegatedAgentRun.cs similarity index 71% rename from src/AgentCore.Application/Runtime/DelegatedAgentRun.cs rename to src/AgentCore.Application/Runtime/Agents/DelegatedAgentRun.cs index a8637e8e..889baaee 100644 --- a/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs +++ b/src/AgentCore.Application/Runtime/Agents/DelegatedAgentRun.cs @@ -1,12 +1,15 @@ using System.Text.Json; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Runtime.Harness; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using AgentCore.Application.Runtime.Turn; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents { #pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. internal static class DelegatedAgentRun @@ -14,7 +17,7 @@ internal static class DelegatedAgentRun /// Runs one inner agent for one delegating tool call. /// The agent being delegated to. /// What the outer agent asked, in words. - /// The parent turn, stripped per K42 — or null outside a turn. + /// The parent turn, stripped of its probe holder — 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 @@ -22,8 +25,7 @@ internal static class DelegatedAgentRun /// 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). + /// 's timeout whenever this delegation started a child that ignores its cancel. /// /// Cancels the run. /// What the inner agent answered, as the outer agent reads it. @@ -39,6 +41,45 @@ internal static class DelegatedAgentRun ArgumentNullException.ThrowIfNull(query); ArgumentNullException.ThrowIfNull(backgroundProviders); + SessionHooks? hooks = nested?.Hooks; + string agentId = inner.Name ?? inner.Id; + long started = TimeProvider.System.GetTimestamp(); + SubagentOutcome outcome = SubagentOutcome.Faulted; + if (hooks is not null) + { + _ = hooks.Raise(new SubagentStarted(hooks.Scope(nested!.TurnIndex, nested.Stage), nested.OuterCallId, agentId)); + } + + try + { + object? answer = await RunInSessionAsync(inner, query, nested, tools, backgroundProviders, cancellationToken) + .ConfigureAwait(false); + outcome = SubagentOutcome.Answered; + return answer; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + outcome = SubagentOutcome.Cancelled; + throw; + } + finally + { + if (hooks is not null) + { + _ = hooks.Raise(new SubagentEnded( + hooks.Scope(nested!.TurnIndex, nested.Stage), nested.OuterCallId, agentId, outcome, TimeProvider.System.GetElapsedTime(started))); + } + } + } + + private static async ValueTask RunInSessionAsync( + AIAgent inner, + string query, + TurnInvocation? nested, + IReadOnlyList? tools, + IReadOnlyList backgroundProviders, + CancellationToken cancellationToken) + { AgentSession session = await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false); try diff --git a/src/AgentCore.Application/Runtime/FallbackAgent.cs b/src/AgentCore.Application/Runtime/Agents/FallbackAgent.cs similarity index 94% rename from src/AgentCore.Application/Runtime/FallbackAgent.cs rename to src/AgentCore.Application/Runtime/Agents/FallbackAgent.cs index 6973e970..9c204bbf 100644 --- a/src/AgentCore.Application/Runtime/FallbackAgent.cs +++ b/src/AgentCore.Application/Runtime/Agents/FallbackAgent.cs @@ -1,8 +1,9 @@ using System.Runtime.CompilerServices; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn.Lifecycle; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents { /// /// Answers a run that threw, or one that spoke no words at all, with the spoken fallback. @@ -42,7 +43,7 @@ protected override async Task RunCoreAsync( response = await base.RunCoreAsync(messages, session, options, cancellationToken) .ConfigureAwait(false); } -#pragma warning disable CA1031 // Section 8.7, row six: the run throws, the turn ends, and the conversation lives. +#pragma warning disable CA1031 // The run throws, the turn ends, and the conversation lives. catch (Exception exception) when (IsFault(exception, cancellationToken)) #pragma warning restore CA1031 { @@ -64,10 +65,6 @@ protected override async Task RunCoreAsync( } /// - /// - /// C# forbids yield return inside a catch (CS1631), so the inner enumerator is - /// driven by hand: the fault goes to a local, and the fallback is emitted after the try/catch. - /// protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, @@ -93,7 +90,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA current = updates.Current; } } -#pragma warning disable CA1031 // Section 8.7, row six. See the buffered path above. +#pragma warning disable CA1031 // See the buffered path above. catch (Exception exception) when (IsFault(exception, cancellationToken)) #pragma warning restore CA1031 { diff --git a/src/AgentCore.Application/Runtime/GraphFaultAgent.cs b/src/AgentCore.Application/Runtime/Agents/Graph/GraphFaultAgent.cs similarity index 98% rename from src/AgentCore.Application/Runtime/GraphFaultAgent.cs rename to src/AgentCore.Application/Runtime/Agents/Graph/GraphFaultAgent.cs index 9ba97fc5..9633e473 100644 --- a/src/AgentCore.Application/Runtime/GraphFaultAgent.cs +++ b/src/AgentCore.Application/Runtime/Agents/Graph/GraphFaultAgent.cs @@ -5,7 +5,7 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents.Graph { /// /// Rethrows the fault a graph node raised, and the caller's cancel. AsAIAgent() reports a fault as an diff --git a/src/AgentCore.Application/Runtime/Agents/Graph/GraphParticipantAgent.cs b/src/AgentCore.Application/Runtime/Agents/Graph/GraphParticipantAgent.cs new file mode 100644 index 00000000..9f9feb27 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Agents/Graph/GraphParticipantAgent.cs @@ -0,0 +1,135 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime.Agents.Graph +{ + /// + /// One agent of a graph row, carrying the turn that runs the graph into each of its runs. + /// + internal sealed class GraphParticipantAgent(AIAgent inner) : DelegatingAIAgent(inner) + { + private static readonly ConditionalWeakTable Graphs = []; + + /// + protected override async ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + AgentSession? graph = CurrentRunContext?.Session; + AgentSession session = await InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + return Tied(session, graph); + } + + /// + protected override async ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + AgentSession? graph = CurrentRunContext?.Session; + AgentSession session = await InnerAgent + .DeserializeSessionAsync(serializedState, jsonSerializerOptions, cancellationToken) + .ConfigureAwait(false); + + return Tied(session, graph); + } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + (AgentRunOptions? filed, TurnInvocation? turn) = Filed(session, options); + try + { + return await InnerAgent.RunAsync(messages, session, filed, cancellationToken).ConfigureAwait(false); + } + finally + { + // A file the store already kept is the caller's, even when the run fails after publishing it. + Deliver(turn, Name ?? Id); + } + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + (AgentRunOptions? filed, TurnInvocation? turn) = Filed(session, options); + + await foreach (AgentResponseUpdate update in InnerAgent + .RunStreamingAsync(messages, session, filed, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + Deliver(turn, Name ?? Id); + } + + Deliver(turn, Name ?? Id); + } + + /// Sends every file the run published so far to the turn's caller, around the workflow. + /// The participant's copy of the turn, or when no turn runs the graph. + /// The participant's name. + private static void Deliver(TurnInvocation? turn, string? author) + { + if (turn is { Files: { } files, Notices: { } notices }) + { + foreach (FileContent file in files.TakeAll()) + { + files.Deliver(file); + notices.Post(new FileNotice(file, author)); + } + } + } + + private static AgentSession Tied(AgentSession session, AgentSession? graph) + { + if (graph is not null) + { + Graphs.AddOrUpdate(session, graph); + } + + return session; + } + + /// Files the graph's current turn for one participant run, and returns the options that carry it. + /// + /// The options: the turn's own when the caller passed none, a copy of the caller's chat options with the turn + /// on, or the caller's unchanged when no turn runs the graph or they are not chat options. And the participant's + /// copy of the turn, or when no turn runs the graph. + /// + private static (AgentRunOptions? Options, TurnInvocation? Turn) Filed(AgentSession? session, AgentRunOptions? options) + { + if (session is null + || !Graphs.TryGetValue(session, out AgentSession? graph) + || TurnRegistry.For(graph) is not { } turn) + { + return (options, null); + } + + TurnInvocation participant = turn with { Clarifications = null, Nested = true, Sources = null, Files = new TurnFiles(turn.Files) }; + TurnRegistry.Set(session, participant); + + // A copy every run: a workflow executor hands the same options object to each of its runs, and + // the framework writes into the options it is given. + if (options?.Clone() is not ChatClientAgentRunOptions carried) + { + return (options is null ? participant.RunOptions() : options, participant); + } + + carried.ChatOptions ??= new ChatOptions(); + carried.ChatOptions.AdditionalProperties ??= []; + carried.ChatOptions.AdditionalProperties[TurnInvocation.ArgumentsKey] = participant; + return (carried, participant); + } + } +} diff --git a/src/AgentCore.Application/Runtime/GraphStateAgent.cs b/src/AgentCore.Application/Runtime/Agents/Graph/GraphStateAgent.cs similarity index 93% rename from src/AgentCore.Application/Runtime/GraphStateAgent.cs rename to src/AgentCore.Application/Runtime/Agents/Graph/GraphStateAgent.cs index f10bc110..5aa5eb35 100644 --- a/src/AgentCore.Application/Runtime/GraphStateAgent.cs +++ b/src/AgentCore.Application/Runtime/Agents/Graph/GraphStateAgent.cs @@ -4,10 +4,10 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents.Graph { /// - /// Files the turn's state snapshot at the head of a guarded row 4 graph run, where the + /// Files the turn's state snapshot at the head of a guarded graph run, where the /// graph-state entry strips it into workflow-scoped state for the guard gates. /// internal sealed class GraphStateAgent : DelegatingAIAgent @@ -15,7 +15,7 @@ internal sealed class GraphStateAgent : DelegatingAIAgent private readonly string _name; /// Puts the state filing in front of one guarded graph. - /// The workflow of row 4, already wrapped by AsAIAgent(). + /// The graph workflow, already wrapped by AsAIAgent(). /// The name of the document, which the failure reports. public GraphStateAgent(AIAgent inner, string name) : base(inner) diff --git a/src/AgentCore.Application/Runtime/ModerationAgent.cs b/src/AgentCore.Application/Runtime/Agents/ModerationAgent.cs similarity index 77% rename from src/AgentCore.Application/Runtime/ModerationAgent.cs rename to src/AgentCore.Application/Runtime/Agents/ModerationAgent.cs index e222cce3..c4dbd064 100644 --- a/src/AgentCore.Application/Runtime/ModerationAgent.cs +++ b/src/AgentCore.Application/Runtime/Agents/ModerationAgent.cs @@ -1,9 +1,12 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Evaluation; +using AgentCore.Application.Hooks.Notices; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents { /// /// Refuses a turn whose caller text the moderation endpoint flagged, before the agent runs. @@ -44,7 +47,7 @@ protected override async Task RunCoreAsync( { ArgumentNullException.ThrowIfNull(messages); - TurnDisposition verdict = await JudgeAsync(messages, cancellationToken).ConfigureAwait(false); + TurnDisposition verdict = await JudgeAsync(messages, options, cancellationToken).ConfigureAwait(false); if (verdict.Moderation is ModerationOutcome.Flagged) { AgentResponse refusal = new(new ChatMessage(ChatRole.Assistant, _refusalReply)); @@ -78,7 +81,7 @@ private async IAsyncEnumerable RunCoreStreamingCoreAsync( AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) { - TurnDisposition verdict = await JudgeAsync(messages, cancellationToken).ConfigureAwait(false); + TurnDisposition verdict = await JudgeAsync(messages, options, cancellationToken).ConfigureAwait(false); if (verdict.Moderation is ModerationOutcome.Flagged) { AgentResponseUpdate refusal = new(ChatRole.Assistant, _refusalReply); @@ -98,11 +101,45 @@ private async IAsyncEnumerable RunCoreStreamingCoreAsync( } } - /// Reads the endpoint's verdict on what the caller said, and never lets it drop a turn. + /// Decides, then raises the verdict at once, so it is stamped when decided and not at the seal. /// The whole request. The caller's words are its last user message. + /// The run's options, which carry the turn the verdict is raised for. /// The host's own token. A cancel it asked for still propagates. /// The verdict, as the disposition the turn loop reads. private async ValueTask JudgeAsync( + IEnumerable messages, + AgentRunOptions? options, + CancellationToken cancellationToken) + { + TurnDisposition verdict = await DecideAsync(messages, cancellationToken).ConfigureAwait(false); + + if (TurnInvocation.From(options) is { Hooks: { } hooks } turn) + { + _ = hooks.Raise(new InputModerated( + hooks.Scope(turn.TurnIndex, turn.Stage), + verdict.Moderation switch + { + ModerationOutcome.Flagged => InputVerdict.Flagged, + ModerationOutcome.Unavailable => InputVerdict.Unavailable, + _ => InputVerdict.Clean, + }, + verdict.FlaggedCategories is { Length: > 0 } joined ? joined.Split(',') : [], + verdict.ModerationReason switch + { + TurnFailureReasons.ModerationTimedOut => ModerationUnavailableReason.TimedOut, + TurnFailureReasons.ModerationFaulted => ModerationUnavailableReason.Threw, + _ => null, + })); + } + + return verdict; + } + + /// Reads the endpoint's verdict on what the caller said, and never lets it drop a turn. + /// The whole request. The caller's words are its last user message. + /// The host's own token. A cancel it asked for still propagates. + /// The verdict, as the disposition the turn loop reads. + private async ValueTask DecideAsync( IEnumerable messages, CancellationToken cancellationToken) { @@ -121,13 +158,13 @@ private async ValueTask JudgeAsync( { // The deadline passed, and not the caller's own token. A cancel the host asked for // still propagates, because that is the host ending the turn and not a slow vendor. - return Unavailable(ConversationSession.ModerationTimedOutReason); + return Unavailable(TurnFailureReasons.ModerationTimedOut); } #pragma warning disable CA1031 // Moderation guards the turn. It must never be the thing that drops it. catch (Exception exception) when (exception is not OperationCanceledException) #pragma warning restore CA1031 { - return Unavailable(ConversationSession.ModerationFaultedReason); + return Unavailable(TurnFailureReasons.ModerationFaulted); } } diff --git a/src/AgentCore.Application/Runtime/RequireOutputAgent.cs b/src/AgentCore.Application/Runtime/Agents/RequireOutputAgent.cs similarity index 89% rename from src/AgentCore.Application/Runtime/RequireOutputAgent.cs rename to src/AgentCore.Application/Runtime/Agents/RequireOutputAgent.cs index 256cbe05..a6273848 100644 --- a/src/AgentCore.Application/Runtime/RequireOutputAgent.cs +++ b/src/AgentCore.Application/Runtime/Agents/RequireOutputAgent.cs @@ -2,14 +2,14 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Agents { /// - /// Fails a row 4 graph run that produced no text, instead of letting it end quietly. + /// Fails a graph run that produced no text, instead of letting it end quietly. /// internal sealed class RequireOutputAgent : DelegatingAIAgent { - /// The reason a row 4 graph that produced no text reports, after it names the graph. + /// The reason a graph that produced no text reports, after it names the graph. internal const string NoOutputMessage = "Row 4 of the section 8.2 compile table ends the run at idle when no outgoing edge guard of " + "the node that holds the run is true. Measured on Microsoft.Agents.AI.Workflows 1.17.0: " @@ -22,7 +22,7 @@ internal sealed class RequireOutputAgent : DelegatingAIAgent private readonly string _name; /// Puts the output check in front of one compiled graph. - /// The workflow of row 4, already wrapped by AsAIAgent(). + /// The graph workflow, already wrapped by AsAIAgent(). /// The name of the document, which the failure reports. public RequireOutputAgent(AIAgent inner, string name) : base(inner) diff --git a/src/AgentCore.Application/Runtime/ClarificationText.cs b/src/AgentCore.Application/Runtime/Clarification/ClarificationText.cs similarity index 94% rename from src/AgentCore.Application/Runtime/ClarificationText.cs rename to src/AgentCore.Application/Runtime/Clarification/ClarificationText.cs index 1f8af618..21bd2a29 100644 --- a/src/AgentCore.Application/Runtime/ClarificationText.cs +++ b/src/AgentCore.Application/Runtime/Clarification/ClarificationText.cs @@ -1,7 +1,7 @@ -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Clarification { /// - /// The sentence §8 specifies for the knowledge probe's note. It rides an empty search result, so it + /// The sentence for the knowledge probe's note. It rides an empty search result, so it /// may say plainly that the answer is not yet known. /// internal static class ClarificationText diff --git a/src/AgentCore.Application/Runtime/Clarifications.cs b/src/AgentCore.Application/Runtime/Clarification/Clarifications.cs similarity index 92% rename from src/AgentCore.Application/Runtime/Clarifications.cs rename to src/AgentCore.Application/Runtime/Clarification/Clarifications.cs index 6f670f68..10bec67a 100644 --- a/src/AgentCore.Application/Runtime/Clarifications.cs +++ b/src/AgentCore.Application/Runtime/Clarification/Clarifications.cs @@ -1,11 +1,11 @@ using AgentCore.Application.Conversation; using Microsoft.Agents.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Clarification { /// /// One conversation's memory of what the knowledge probe has asked and named, and the per-turn latch that - /// lets several search-tool calls in one turn share a single probe (K36, K43). + /// lets several search-tool calls in one turn share a single probe. /// internal sealed class Clarifications { @@ -90,9 +90,6 @@ internal Probe ClaimProbe() /// Opens a new turn: drops the turn's probe latch, so a fresh turn claims and replays its own /// probe rather than the turn before it. /// - /// - /// Must run exactly once per turn, from ConversationTurnRunner.BeginTurn and nowhere else. - /// internal void BeginTurn() { lock (_gate) @@ -144,12 +141,6 @@ internal IReadOnlyDictionary Spent() /// Puts back what an earlier session of this conversation spent of its ask budget. /// What read from that session. - /// - /// The record of what was last named is deliberately not restored: it belongs to a turn the - /// reconnected caller is no longer in. Only the budget has to survive, and for the reason - /// gives — a caller who drops and comes back must not buy a fresh - /// maxAsks and hear the same question all over again. - /// internal void RestoreSpent(IReadOnlyDictionary spent) { ArgumentNullException.ThrowIfNull(spent); @@ -196,11 +187,6 @@ internal enum LastNamedKind /// /// One turn's probe latch, held by the caller that claimed it. /// - /// - /// The payload travels on the handle rather than being looked up again on publish, so a search - /// that outlives its turn — an abandoned tool task, a cancellation callback that runs late — - /// resolves the latch of the turn it belongs to and cannot touch the one the next turn opened. - /// internal sealed class Probe { private readonly TaskCompletionSource> _payload; @@ -256,7 +242,7 @@ internal Probe AsLoser() } } - /// One slot's record of what was last named to the caller (K37). + /// One slot's record of what was last named to the caller. internal readonly record struct LastNamed(LastNamedKind Kind, IReadOnlySet? Values) { /// Nothing has been named yet. @@ -272,7 +258,7 @@ internal static LastNamed Of(IReadOnlySet values) return new LastNamed(LastNamedKind.Set, values); } - /// What the probe would record for one candidate set (K37). + /// What the probe would record for one candidate set. /// The candidates the probe is about to name. /// The document's maxCandidates. /// @@ -292,7 +278,7 @@ internal static LastNamed For(IReadOnlyCollection candidates, int maxCan /// Whether this record names the same thing as . Two too-many /// records match regardless of their underlying sets: a list neither ever spoke cannot be /// compared, and two different over-maxCandidates sets are meant to read as the - /// identical question (K34). + /// identical question. /// /// The record to compare against. /// when the caller would hear the same question twice. diff --git a/src/AgentCore.Application/Runtime/Compaction/CompactionStrategyFactory.cs b/src/AgentCore.Application/Runtime/Compaction/CompactionStrategyFactory.cs index 9c7fc7d5..c150e481 100644 --- a/src/AgentCore.Application/Runtime/Compaction/CompactionStrategyFactory.cs +++ b/src/AgentCore.Application/Runtime/Compaction/CompactionStrategyFactory.cs @@ -1,3 +1,4 @@ +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Compaction; @@ -40,15 +41,17 @@ public static CompactionStages Create(IChatClient summariser, CompactionTrigger /// Builds the providers in chain order: the cap always runs, the summary needs history to persist its row. /// The cap provider and what the summary provider builds its strategy from. - /// Store 1, or when the session carries no history. + /// The message store, or when the session carries no history. + /// The hooks of this compile. /// The cap, then the summary when there is somewhere to write it. - public static AIContextProvider[] BuildProviders(CompactionStages stages, AgentCoreChatHistoryProvider? history) + public static AIContextProvider[] BuildProviders(CompactionStages stages, AgentCoreChatHistoryProvider? history, HookRuntime hooks) { ArgumentNullException.ThrowIfNull(stages); + ArgumentNullException.ThrowIfNull(hooks); return history is null ? [stages.Cap] - : [stages.Cap, new SummaryRowProvider(history, stages.Summariser, stages.Summary)]; + : [stages.Cap, new SummaryRowProvider(history, stages.Summariser, stages.Summary, hooks)]; } } #pragma warning restore MAAI001 diff --git a/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs b/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs index 0f9cfd27..18807d33 100644 --- a/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs +++ b/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs @@ -1,4 +1,7 @@ using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; @@ -25,18 +28,23 @@ internal sealed class SummaryRowProvider : AIContextProvider private readonly Func _summary; - /// Store 1, which says what a compaction may cover and takes the row. + private readonly HookRuntime _hooks; + + /// The message store, which says what a compaction may cover and takes the row. /// The chat client the strategy calls. Wrapped fresh each invocation, so the notice tracks the call it actually makes. /// Builds the strategy that replaces the oldest messages with one summary, over the client it is handed. - public SummaryRowProvider(AgentCoreChatHistoryProvider history, IChatClient summariser, Func summary) + /// The hooks of this compile; they run inside each compaction. + public SummaryRowProvider(AgentCoreChatHistoryProvider history, IChatClient summariser, Func summary, HookRuntime hooks) { ArgumentNullException.ThrowIfNull(history); ArgumentNullException.ThrowIfNull(summariser); ArgumentNullException.ThrowIfNull(summary); + ArgumentNullException.ThrowIfNull(hooks); _history = history; _summariser = summariser; _summary = summary; + _hooks = hooks; } /// @@ -63,14 +71,23 @@ protected override async ValueTask InvokingCoreAsync( List view = [.. messages.Take(floor.Messages.Count)]; NoticingChatClient client = new(_summariser, turn?.Notices); - CompactionStrategy strategy = _summary(client); + GatedSummariser gated = new(client, _hooks, turn, view.Count); + CompactionStrategy strategy = _summary(gated); try { List output = [.. await CompactionProvider.CompactAsync(strategy, view, logger, cancellationToken).ConfigureAwait(false)]; + if (gated.Cancelled) + { + RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Cancelled); + PostEnd(turn, client, "unchanged"); + return input; + } + if (output.Count == view.Count && output.Zip(view).All(pair => ReferenceEquals(pair.First, pair.Second))) { + RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Unchanged); PostEnd(turn, client, "unchanged"); return input; } @@ -82,6 +99,7 @@ protected override async ValueTask InvokingCoreAsync( Log.TranscriptCompactionUnsupported(logger, turn.ConversationId, turn.TurnIndex); } + RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Unchanged); PostEnd(turn, client, "unchanged"); return input; } @@ -89,10 +107,12 @@ protected override async ValueTask InvokingCoreAsync( int coversUpTo = floor.Messages[kept - 1].LastOrdinal; if (coversUpTo <= floor.CoversUpTo || !_history.Compact(session, output[0], coversUpTo, floor.Revision)) { + RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Unchanged); PostEnd(turn, client, "unchanged"); return input; } + RaiseCompacted(turn, gated, messages.Count, 1 + messages.Count - kept, CompactionOutcome.Compacted); PostEnd(turn, client, "compacted"); return new AIContext { @@ -105,16 +125,33 @@ protected override async ValueTask InvokingCoreAsync( catch (Exception exception) when (exception is not OperationCanceledException) #pragma warning restore CA1031 { + if (gated.Cancelled) + { + RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Cancelled); + PostEnd(turn, client, "unchanged"); + return input; + } + if (turn is not null) { Log.TranscriptCompactionFailed(logger, turn.ConversationId, turn.TurnIndex, exception); } + RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Failed); PostEnd(turn, client, "failed"); return input; } } + /// Raises the compaction notice when the strategy actually asked for a summary: no ask, no compaction. + private static void RaiseCompacted(TurnInvocation? turn, GatedSummariser gated, int before, int after, CompactionOutcome outcome) + { + if (gated.Asked && turn?.Hooks is { } hooks) + { + _ = hooks.Raise(new Compacted(hooks.Scope(turn.TurnIndex, turn.Stage), before, after, outcome)); + } + } + /// Posts the end notice, but only when the summariser was actually called: no call, no flicker. private static void PostEnd(TurnInvocation? turn, NoticingChatClient client, string outcome) { diff --git a/src/AgentCore.Application/Runtime/ConversationEvent.cs b/src/AgentCore.Application/Runtime/ConversationEvent.cs deleted file mode 100644 index 13184037..00000000 --- a/src/AgentCore.Application/Runtime/ConversationEvent.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.ObjectModel; - -namespace AgentCore.Application.Runtime -{ - /// - /// One fact about a conversation, as the turn loop saw it. Nothing edits it afterwards. - /// - public sealed record ConversationEvent - { - /// Gets the id of the conversation the fact belongs to. - public required string ConversationId { get; init; } - - /// Gets what happened. - public required ConversationEventKind Kind { get; init; } - - /// Gets the moment it happened, read from the clock of the session. - public required DateTimeOffset OccurredAt { get; init; } - - /// - /// Gets the identity of this fact, or for a diagnostic-only event, which - /// no audit row records. - /// - public Guid? EventId { get; init; } - - /// - /// Gets the zero-based index of the turn the fact belongs to, or for a - /// fact about the conversation itself. - /// - public int? TurnIndex { get; init; } - - /// - /// Gets the of the earlier fact this one corrects, or - /// when it corrects nothing. - /// - public Guid? AmendsEventId { get; init; } - - /// Gets the detail of the fact, keyed by name. It is empty when the kind needs none. - public IReadOnlyDictionary Payload { get; init; } = ReadOnlyDictionary.Empty; - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationEventChain.cs b/src/AgentCore.Application/Runtime/ConversationEventChain.cs deleted file mode 100644 index 47b51cc2..00000000 --- a/src/AgentCore.Application/Runtime/ConversationEventChain.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System.Globalization; -using AgentCore.Domain; -using AgentCore.Domain.Audit; - -namespace AgentCore.Application.Runtime -{ - /// - /// The event chain of one conversation. It gives every fact its identity, hands it to the observers, and - /// closes the chain once. - /// - internal sealed class ConversationEventChain - { - private readonly string _conversationId; - - private readonly ConversationObserverDispatcher _observers; - - private readonly TimeProvider _time; - - private int _ended; - - internal ConversationEventChain(string conversationId, ConversationObserverDispatcher observers, TimeProvider time) - { - _conversationId = conversationId; - _observers = observers; - _time = time; - } - - /// Gets whether the chain already closed. Nothing may be appended behind conversation.ended. - internal bool HasEnded => Volatile.Read(ref _ended) == 1; - - /// Raises one durable fact of this conversation, and gives it its identity. - internal Guid Raise( - ConversationEventKind kind, - DateTimeOffset occurredAt, - int? turnIndex, - Guid? amends = null, - IReadOnlyDictionary? payload = null) - { - Guid eventId = Guid.CreateVersion7(); - - Dispatch(kind, occurredAt, turnIndex, eventId, amends, payload); - - return eventId; - } - - /// Raises one fact that is counted and logged and stored nowhere. - /// What happened. The chain of D23 holds no row for it. - /// When it happened. - /// The turn it belongs to. - /// The detail the fact carries. - internal void RaiseDiagnostic( - ConversationEventKind kind, - DateTimeOffset occurredAt, - int? turnIndex, - IReadOnlyDictionary? payload = null) - { - Dispatch(kind, occurredAt, turnIndex, eventId: null, amends: null, payload); - } - - /// Closes the chain of this conversation, once. - /// Why the conversation ended. - /// The moment it ended. - /// The stage the machine stopped in, or . - /// when this conversation wrote the event, and when it already had. - internal bool EndConversation(ConversationEndReason reason, DateTimeOffset endedAt, string? terminalStage = null) - { - // The token is read first, so a value outside the closed set writes no event at all. - string token = ConversationEndReasons.ToToken(reason); - - if (Interlocked.Exchange(ref _ended, 1) == 1) - { - return false; - } - - Dictionary payload = new(StringComparer.Ordinal) - { - [AuditPayloadKeys.EndReason] = token, - }; - - if (terminalStage is { Length: > 0 }) - { - payload[AuditPayloadKeys.StageAfter] = terminalStage; - } - - _ = Raise(ConversationEventKind.ConversationEnded, endedAt, turnIndex: null, payload: payload); - - return true; - } - - /// 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 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 generatedText, string heard, string? toolFault, TimeSpan? played) - { - (int turnIndex, DateTimeOffset endedAt) = (result.TurnIndex, result.EndedAt); - - if (toolFault is not null) - { - _ = Raise( - ConversationEventKind.ToolFailed, - endedAt, - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ToolError] = toolFault, - }); - } - - Guid completed = Raise( - ConversationEventKind.TurnCompleted, - endedAt, - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(generatedText).Value, - [AuditPayloadKeys.StageBefore] = result.StageBefore, - [AuditPayloadKeys.StageAfter] = result.StageAfter, - }); - - if (result.Cut is not null) - { - RaiseReplyInterrupted(turnIndex, endedAt, completed, heard, played); - } - - return completed; - } - - /// Raises the amendment pair's second half: the barge-in that corrects one turn. - /// The turn whose reply was cut. - /// 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, 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) - { - 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. - /// The turn that just ran. - /// What the layers reported, or . - internal void RaiseModeration(int turnIndex, TurnDisposition? disposition) - { - switch (disposition?.Moderation) - { - case ModerationOutcome.Flagged: - _ = Raise( - ConversationEventKind.PromptFlagged, - _time.GetUtcNow(), - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ModerationCategories] = disposition.FlaggedCategories ?? string.Empty, - }); - break; - - case ModerationOutcome.Unavailable: - RaiseDiagnostic( - ConversationEventKind.ModerationUnavailable, - _time.GetUtcNow(), - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [ConversationEventPayloadKeys.Reason] = - disposition.ModerationReason ?? ConversationSession.ModerationFaultedReason, - }); - break; - - case ModerationOutcome.Clean: - RaiseDiagnostic(ConversationEventKind.ModerationClean, _time.GetUtcNow(), turnIndex); - break; - - default: - break; - } - } - - /// Raises the fact of one tool call that did not run to completion. - /// The turn the conversation belongs to. - /// What the function-invocation loop saw. - internal void RaiseToolFailure(int turnIndex, ToolFailure failure) - { - _ = Raise( - ConversationEventKind.ToolFailed, - _time.GetUtcNow(), - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ToolName] = failure.ToolName, - [AuditPayloadKeys.ToolCallId] = failure.ToolCallId, - [AuditPayloadKeys.ToolFailureKind] = ToolFailureKinds.ToToken(failure.Kind), - [AuditPayloadKeys.ToolError] = failure.Message, - }); - } - - /// Raises the fact of one store 1 write the backing store refused. - internal void RaiseDroppedTranscriptWrite(int turnIndex, Exception exception) - { - RaiseDiagnostic( - ConversationEventKind.TranscriptWriteFailed, - _time.GetUtcNow(), - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [ConversationEventPayloadKeys.Reason] = $"{exception.GetType().Name}: {exception.Message}", - }); - } - - /// Raises the fact of one store 1 read that failed as a turn opened. - internal void RaiseFailedTranscriptResync(int turnIndex, Exception exception) - { - RaiseDiagnostic( - ConversationEventKind.TranscriptResyncFailed, - _time.GetUtcNow(), - turnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [ConversationEventPayloadKeys.Reason] = $"{exception.GetType().Name}: {exception.Message}", - }); - } - - /// Hands one fact to everything watching the conversation, and never waits for it. - /// What happened. - /// When it happened. - /// The turn it belongs to, or . - /// The identity it took, or when it took none. - /// The identity it corrects, or . - /// The detail it carries. - private void Dispatch( - ConversationEventKind kind, - DateTimeOffset occurredAt, - int? turnIndex, - Guid? eventId, - Guid? amends, - IReadOnlyDictionary? payload) - { - _observers.Dispatch(new ConversationEvent - { - ConversationId = _conversationId, - Kind = kind, - OccurredAt = occurredAt, - EventId = eventId, - TurnIndex = turnIndex, - AmendsEventId = amends, - Payload = payload ?? new Dictionary(StringComparer.Ordinal), - }); - } - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationEventKind.cs b/src/AgentCore.Application/Runtime/ConversationEventKind.cs deleted file mode 100644 index 5248267c..00000000 --- a/src/AgentCore.Application/Runtime/ConversationEventKind.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace AgentCore.Application.Runtime -{ - /// - /// The closed set of facts one conversation raises to its observers. - /// - public enum ConversationEventKind - { - /// A session of the conversation started. It is the first fact of every session. - ConversationStarted = 0, - - /// Moderation flagged what the CALLER said, so the agent refused to answer that turn. - PromptFlagged = 1, - - /// The moderation endpoint did not answer in time, or it faulted, so the turn ran unchecked. - ModerationUnavailable = 2, - - /// The moderation endpoint answered, and it flagged nothing. - ModerationClean = 3, - - /// A tool failed its whole retry budget, and the turn spoke the fallback instead. - ToolFailed = 4, - - /// The run returned quietly with no text, so the turn spoke the fallback. - EmptyReply = 5, - - /// The extractor returned an invalid object, so the slots stayed unchanged. - ExtractionFailed = 6, - - /// One turn ran to the end, and the caller heard the whole reply. - TurnCompleted = 7, - - /// The caller spoke over a reply, so the reply stopped early. - ReplyInterrupted = 8, - - /// A caller sent an earlier message again, so the turns after it were withdrawn. - TurnSuperseded = 12, - - /// A store 1 write was refused, so the durable copy of some words was lost. - TranscriptWriteFailed = 10, - - /// The host ended the conversation. It is the last fact of the session that ended it. - ConversationEnded = 9, - - /// A resumed conversation could not restore part of its stored state: the document changed under it. - StateRestorePartial = 11, - - /// - /// Store 1 could not be read as a turn opened, so the turn ran on the words the session already - /// 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 deleted file mode 100644 index 9ae0dce4..00000000 --- a/src/AgentCore.Application/Runtime/ConversationEventKinds.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Frozen; -using AgentCore.Domain.Audit; - -namespace AgentCore.Application.Runtime -{ - /// - /// What one is to the audit vocabulary, in one place. - /// - internal static class ConversationEventKinds - { - /// The eight kinds the chain stores, and the audit kind each one is stored as. - private static readonly FrozenDictionary AuditKinds = - new Dictionary - { - [ConversationEventKind.ConversationStarted] = AuditEventKind.ConversationStarted, - [ConversationEventKind.PromptFlagged] = AuditEventKind.PromptFlagged, - [ConversationEventKind.ToolFailed] = AuditEventKind.ToolFailed, - [ConversationEventKind.TurnCompleted] = AuditEventKind.TurnCompleted, - [ConversationEventKind.ReplyInterrupted] = AuditEventKind.ReplyInterrupted, - [ConversationEventKind.ConversationEnded] = AuditEventKind.ConversationEnded, - [ConversationEventKind.TurnSuperseded] = AuditEventKind.TurnSuperseded, - [ConversationEventKind.TurnRefused] = AuditEventKind.TurnRefused, - }.ToFrozenDictionary(); - - /// - /// Reads the audit kind one conversation event maps to, when the chain stores it at all. - /// - public static bool TryGetAuditKind(ConversationEventKind kind, out AuditEventKind auditKind) - { - return AuditKinds.TryGetValue(kind, out auditKind); - } - - /// - /// Names one kind for a log line. - /// - public static string ToToken(ConversationEventKind kind) - { - return kind switch - { - ConversationEventKind.ConversationStarted => AuditEventKinds.ToToken(AuditEventKind.ConversationStarted), - ConversationEventKind.PromptFlagged => AuditEventKinds.ToToken(AuditEventKind.PromptFlagged), - ConversationEventKind.ToolFailed => AuditEventKinds.ToToken(AuditEventKind.ToolFailed), - ConversationEventKind.TurnCompleted => AuditEventKinds.ToToken(AuditEventKind.TurnCompleted), - 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", - ConversationEventKind.ExtractionFailed => "extraction.failed", - 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. - _ => kind.ToString(), - }; - } - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationEventPayloadKeys.cs b/src/AgentCore.Application/Runtime/ConversationEventPayloadKeys.cs deleted file mode 100644 index 31529845..00000000 --- a/src/AgentCore.Application/Runtime/ConversationEventPayloadKeys.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace AgentCore.Application.Runtime -{ - /// - /// The payload keys a diagnostic-only carries. - /// - internal static class ConversationEventPayloadKeys - { - /// - /// Why a diagnostic-only event happened, in the words the turn loop used. - /// - public const string Reason = "reason"; - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationObserverDispatcher.cs b/src/AgentCore.Application/Runtime/ConversationObserverDispatcher.cs deleted file mode 100644 index fbbebe89..00000000 --- a/src/AgentCore.Application/Runtime/ConversationObserverDispatcher.cs +++ /dev/null @@ -1,209 +0,0 @@ -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Ports; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace AgentCore.Application.Runtime -{ - /// - /// Hands each fact of a conversation to every observer, in order, and never waits for one. - /// - /// - /// - /// The "never sit on the turn" rule of lives here, once, for every - /// observer. Section 7 measures a durable insert at 13 ms at p50 and 32 ms at p99, against 91 - /// nanoseconds to enqueue, so an observer that completes synchronously costs the caller the enqueue - /// and nothing else, and an observer that does not is observed on a separate task. The reply leaves - /// while the row is still being written. - /// - /// - /// Nothing here propagates. An observer records the conversation and is never a part of it, so one that - /// throws is logged once and the turn goes on. That holds for a fault raised before the first await - /// and for a fault raised long after the turn ended, and it holds for each observer separately: a - /// refusing sink does not cost the counters or the log their reading of the same fact. - /// - /// - /// Order is a guarantee, and it is a guarantee per observer. A bare fire-and-forget task per - /// event lets two slow deliveries land out of order, and an audit chain whose events arrive shuffled - /// is not a record of the conversation. Every observer therefore has a tail of its own in - /// , and a dispatch chains onto that observer's tail under : - /// the facts of one dispatcher reach ONE observer in the order the conversation produced them, however long - /// any one of them takes. That is the whole of what the chain of D23 asks for. - /// - /// - /// It is deliberately not one guarantee across observers. A single shared tail would queue every - /// later counter and every later log line behind a sink still writing an earlier row, so the two - /// fast readings would pay the slow one's price on every event after it, for the rest of the conversation. - /// With a tail each, a slow observer delays only itself, and telemetry and logging keep their old - /// synchronous cost whatever the sink is doing. - /// - /// - /// The lock is held across the synchronous head of every delivery, which is what makes the fast path - /// and the queue one decision rather than a race between two threads. That window is the work an - /// observer does before its first await, and the port already forbids that work from blocking: a - /// counter, a log line, and an enqueue all fit inside it. It is also what fixes the order of those - /// heads, so every observer is still offered a fact in the order the observers were registered. - /// - /// - /// The fast path keeps the old cost exactly. An observer that owes nothing runs on the caller's - /// thread up to its first incomplete await, and a dispatch in which every observer completes - /// synchronously allocates no continuation and touches nothing after the lock. This is what the whole - /// turn loop does when the sink is a queue, which is the shape D23 asks for. - /// - /// - /// One dispatcher belongs to one conversation, because the ordering guarantee is per dispatcher and a shared - /// instance would make every conversation queue behind every other. The observers behind it are shared, and - /// they hold no per-call state. - /// - /// - internal sealed class ConversationObserverDispatcher - { - private readonly IConversationObserver[] _observers; - private readonly ILogger _logger; - private readonly Lock _gate = new(); - - /// - /// The delivery each observer's later events queue behind, indexed alongside - /// . An entry is while that observer owes nothing, - /// which is the fast path. None of these tasks ever faults. - /// - private readonly Task?[] _tails; - - /// Creates the dispatcher of one conversation. - /// - /// Everything that watches the conversation. Each one takes every fact, in the order they are given here. - /// An empty set is legal, and it makes every dispatch a no-op. - /// - /// - /// The logger an observer's fault is reported to, or for - /// . The library never throws for want of one. - /// - public ConversationObserverDispatcher(IEnumerable observers, ILogger? logger = null) - { - ArgumentNullException.ThrowIfNull(observers); - - _observers = [.. observers]; - _tails = new Task?[_observers.Length]; - _logger = logger ?? NullLogger.Instance; - } - - /// Gets the number of observers watching the conversation. - public int Count => _observers.Length; - - /// Hands one fact to every observer, and never waits for them. - /// What happened. - /// is . - /// - /// It returns as soon as the observers have accepted the fact, which for a well-behaved observer - /// is before it returns at all. Nothing an observer does can make this method throw or block. - /// - public void Dispatch(ConversationEvent conversationEvent) - { - ArgumentNullException.ThrowIfNull(conversationEvent); - - if (_observers.Length == 0) - { - return; - } - - lock (_gate) - { - // The observers are offered the fact one after another rather than all at once, so the - // order they were registered in is the order they read the conversation in. - for (int index = 0; index < _observers.Length; index++) - { - Task? tail = _tails[index]; - - // This observer owes nothing, so the fact runs here and now, on the caller's thread, - // up to its first await if it has one. Order is kept for free: there is nothing left - // for it to follow. - if (tail is null || tail.IsCompleted) - { - Task delivery = DeliverAsync(_observers[index], conversationEvent); - _tails[index] = delivery.IsCompleted ? null : delivery; - continue; - } - - // An earlier fact is still open at THIS observer. This one waits behind it, so that - // observer never sees the turns of a conversation out of the order the conversation ran them — and - // every other observer still reads this fact now, whatever the slow one is doing. - _tails[index] = ContinueAsync(tail, _observers[index], conversationEvent); - } - } - } - - /// Waits for the earlier fact of one observer, then delivers this one to it. - /// The delivery of that observer already outstanding. - /// The observer both facts belong to. - /// The fact queued behind the earlier one. - /// A task that always completes, and never faults. - private async Task ContinueAsync(Task previous, IConversationObserver observer, ConversationEvent conversationEvent) - { - try - { - await previous.ConfigureAwait(false); - } -#pragma warning disable CA1031 // A delivery reports its own faults, and a broken one must not stop the next fact. - catch (Exception) -#pragma warning restore CA1031 - { - // DeliverAsync swallows everything an observer raises and everything the report of that - // raises, so nothing is expected here. It is caught all the same, because the fact behind - // this one is owed to the observer whatever happened to the fact before it. - } - - await DeliverAsync(observer, conversationEvent).ConfigureAwait(false); - } - - /// Offers one fact to one observer. - /// The observer the fact is owed to. - /// What happened. - /// A task that always completes, and never faults. - /// - /// A slow observer delays only the later facts of its own tail, off the turn, where the cost is - /// nobody's. - /// - private async Task DeliverAsync(IConversationObserver observer, ConversationEvent conversationEvent) - { - try - { - // CancellationToken.None: the record belongs to the conversation, not to the turn the caller may - // have cancelled. - ValueTask pending = observer.OnConversationEventAsync(conversationEvent, CancellationToken.None); - if (pending.IsCompletedSuccessfully) - { - return; - } - - await pending.ConfigureAwait(false); - } -#pragma warning disable CA1031 // An observer records the conversation and is never a part of it. - catch (Exception exception) -#pragma warning restore CA1031 - { - Report(conversationEvent, exception); - } - } - - /// Writes the one line an observer's fault is worth, and never throws. - /// The fact the observer refused. - /// The cause. - private void Report(ConversationEvent conversationEvent, Exception exception) - { - try - { - // The token is read from ConversationEventKinds, which is also what AuditConversationObserver and - // TelemetryConversationObserver ask about a kind. One mapping, so a line and a row never - // disagree about what the observer refused. - Log.AuditAppendFailed(_logger, conversationEvent.ConversationId, ConversationEventKinds.ToToken(conversationEvent.Kind), exception); - } -#pragma warning disable CA1031 // A logger that refuses the report is still not a part of the conversation. - catch (Exception) -#pragma warning restore CA1031 - { - // The report itself failed, and there is nowhere left to report that. A delivery that - // never faults is what the rest of this class promises, so the promise is kept here. - } - } - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationObservers.cs b/src/AgentCore.Application/Runtime/ConversationObservers.cs deleted file mode 100644 index 4000bc08..00000000 --- a/src/AgentCore.Application/Runtime/ConversationObservers.cs +++ /dev/null @@ -1,84 +0,0 @@ -using AgentCore.Application.Audit; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Ports; -using Microsoft.Extensions.Logging; - -namespace AgentCore.Application.Runtime -{ - /// - /// Assembles the readings of one conversation, in the order every conversation then pays for. - /// - /// - /// - /// The composition root resolves an — always one, since - /// providers.audit falls back to the in-process memory kind — and an - /// , and knows nothing of . This is what turns them - /// into the telemetry, logging, and audit readings of one fact. It is composition, so it belongs to - /// the composition root and not to : the factory is handed the - /// finished list and holds no opinion about what is in it. - /// - /// - /// The observers it builds are shared and hold no per-call state, so one list serves every conversation. - /// The ordering guarantee is not here either — each session gets a - /// of its own, because that guarantee is per instance and a - /// shared one would make every conversation queue behind every other. - /// - /// - public static class ConversationObservers - { - /// Builds the list one host's bindings ask for. - /// - /// The sink the chain of D23 is appended to. There is always one: the composition root reads - /// providers.audit, which names the in-process memory kind when the document names - /// no vendor, so a host that configures nothing still hands a real sink down rather than a hole. - /// One sink serves every conversation, because a session names itself on every event. - /// - /// - /// The logger the three "log once" rows of section 8.7 write to, or for a - /// logger that writes nowhere. The library never throws for want of one. - /// - /// - /// The host's own readings of a conversation, or for a host that adds none. - /// - /// The readings, in the order the dispatcher offers each fact to them. - /// is . - public static IReadOnlyList Standard( - IAuditSinkPort auditSink, - ILogger? logger, - IEnumerable? hostObservers = null) - { - ArgumentNullException.ThrowIfNull(auditSink); - - // The sink goes LAST. The dispatcher offers one fact to each observer in turn, in - // this order, and that order is what a reading of the same fact costs the turn: the counters - // of section 8.6 and the rows of section 8.7 are taken above the enqueue, exactly as the old - // turn loop took them. The counter and the line always answer at once; a sink is the one that - // may not, and a durable insert is measured at 13 ms p50 and 32 ms p99 in section 7. - // - // What the order no longer decides is what a LATER fact costs. Each observer has a tail of - // its own inside the dispatcher, so a sink still writing an earlier row holds up nothing but - // its own next row: telemetry and logging keep their old synchronous cost on every event - // whatever the sink is doing, and the remarks on TelemetryConversationObserver still hold that a kind - // is counted whatever the sink then does with it. Order is guaranteed per observer, which is - // all the chain of D23 needs, and the slow one pays for itself alone, off the turn. - // - // All three readings are unconditional. The counters of section 8.6 and the rows of section - // 8.7 are this library's own and no host opts out of them, and the audit reading is now in - // the same position: the composition root resolves providers.audit into a sink for every - // host, falling back to the in-process memory kind when the document names no vendor, so - // there is no longer a "bound nothing" shape for this list to have. The chain of D23 is - // therefore written on every conversation, and a host that wants it durable changes where it goes, - // not whether it is taken. - IConversationObserver[] library = - [new TelemetryConversationObserver(), new LoggingConversationObserver(logger), new AuditConversationObserver(auditSink)]; - - // The host's own observers go after all three. An observer bound through - // AgentCoreOptions.UseObservers is code this library did not write and cannot measure, and - // the head of a delivery — the work before the first await — is the one part every observer - // still runs one after another. Putting the host last is what keeps the counters, the rows, - // and the enqueue at exactly the cost they had before any host bound anything. Past that head - // the order buys nothing and costs nothing: each observer has a tail of its own. - return hostObservers is null ? library : [.. library, .. hostObservers]; - } - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs b/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs deleted file mode 100644 index ef737bec..00000000 --- a/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs +++ /dev/null @@ -1,194 +0,0 @@ -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime.Harness; -using AgentCore.Domain.Audit; -using Microsoft.Agents.AI; - -namespace AgentCore.Application.Runtime -{ - internal sealed class ConversationSessionLifetime - { - private readonly ConversationSession _session; - - private readonly Lock _replacedGate = new(); - - private int _backgroundReleased; - - private Task _replacedReleases = Task.CompletedTask; - - internal ConversationSessionLifetime(ConversationSession session) - { - ArgumentNullException.ThrowIfNull(session); - _session = session; - } - - /// Closes the conversation, and writes the last event of its chain. - /// Why the conversation ended, as one member of the closed set. - /// - /// when this conversation wrote the event, and when the - /// conversation had already ended. - /// - /// - /// is not a member of the closed set. - /// - internal bool EndConversation(ConversationEndReason reason) - { - 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 when the conversation ends, and by the - /// session owner when it closes or unloads the session. - /// - internal void DeleteWorkspace() - { - _session.WorkspaceRoot?.Delete(_session.Logger); - } - - /// - /// 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 - /// children. Release precedes the shells: a child cancelled here may be inside a shell tool. - /// - internal async ValueTask ReleaseBackgroundSessionsAsync() - { - if (Interlocked.Exchange(ref _backgroundReleased, 1) == 1 - || _session.Compiled.BackgroundProviders.Count == 0 - || _session.Ledger.Session() is not { } session) - { - return; - } - - await BackgroundSessionRelease.ReleaseAsync( - _session.Compiled.BackgroundProviders, session, _session.ConversationId, _session.Logger, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// Disposes this conversation's shell executors, if it has any. does not conversation - /// this: a tool that ends the conversation mid-turn still needs the shell for the rest of that turn, so - /// only the turn-end path and tear it down. - /// - 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/ConversationCutTracker.cs b/src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs similarity index 87% rename from src/AgentCore.Application/Runtime/ConversationCutTracker.cs rename to src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs index 68a98ee2..1c7813a2 100644 --- a/src/AgentCore.Application/Runtime/ConversationCutTracker.cs +++ b/src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs @@ -1,4 +1,8 @@ -namespace AgentCore.Application.Runtime +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Runtime.Cut { internal sealed class ConversationCutTracker { @@ -22,7 +26,7 @@ internal ConversationCutTracker(ConversationSession session) _session = session; } - /// Cuts one turn's reply by the Cut rule (design section 3). + /// Cuts one turn's reply. /// The turn the user was seeing or hearing. /// What reached the user. /// @@ -171,7 +175,7 @@ private async Task WaitForSlotAsync(bool take, long started, CancellationToken c } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone); + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusal.Gone); throw; } } @@ -186,6 +190,7 @@ internal bool TryEnterTurn() { if (_closed) { + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusal.Disposed); throw new ObjectDisposedException( nameof(ConversationSession), $"The conversation '{_session.ConversationId}' was disposed, so it runs no further turn."); } @@ -224,12 +229,30 @@ internal Task CloseTurnsAsync() } } + /// Reads the index of the turn that holds the slot, or when none does. + internal int? RunningTurnIndex() + { + lock (_session.TurnLock) + { + if (!_running) + { + return null; + } + + // The seal counts the state's turn index up before the slot closes; the open slot still names its turn. + return _slot is not null ? _slotTurnIndex : _session.State.TurnIndex; + } + } + private Task FreedLocked() { return (_freed ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).Task; } - /// Frees the turn slot after a turn refused to start left it taken. + /// + /// Frees the turn slot, after the turn ended or was refused before it started, and raises an end that waited on + /// that turn before anything awaiting the slot resumes. + /// internal void ReleaseTurn() { TaskCompletionSource? freed; @@ -238,6 +261,9 @@ internal void ReleaseTurn() _running = false; freed = _freed; _freed = null; + _session.Lifetime.Ending.RaisePending(); + _session.ToolPairs.AfterTurn(); + _session.Lifetime.SaveEndAfterTurn(); } _ = freed?.TrySetResult(); @@ -272,9 +298,9 @@ internal void EndRun(TurnCutSlot slot) /// 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 + // An ended conversation takes no later fact. + if (_session.Lifetime.Ending.Requested + || _session.AmendableEventId is null || _session.LastTurn is not { } finished || (finished.Cut is not null) != recut || finished.TurnIndex != turnIndex @@ -293,8 +319,7 @@ internal bool AmendTurn(int turnIndex, TurnCut cut, bool recut) _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); + new ConversationTurnCommit(_session).RaiseCut(finished.TurnIndex, shown, cut.Played); return true; } diff --git a/src/AgentCore.Application/Runtime/TurnCut.cs b/src/AgentCore.Application/Runtime/Cut/TurnCut.cs similarity index 90% rename from src/AgentCore.Application/Runtime/TurnCut.cs rename to src/AgentCore.Application/Runtime/Cut/TurnCut.cs index 5afe2fdd..a1c3f9fc 100644 --- a/src/AgentCore.Application/Runtime/TurnCut.cs +++ b/src/AgentCore.Application/Runtime/Cut/TurnCut.cs @@ -1,4 +1,4 @@ -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Cut { /// What the user saw or heard of a reply before it was cut. /// diff --git a/src/AgentCore.Application/Runtime/TurnCutSlot.cs b/src/AgentCore.Application/Runtime/Cut/TurnCutSlot.cs similarity index 97% rename from src/AgentCore.Application/Runtime/TurnCutSlot.cs rename to src/AgentCore.Application/Runtime/Cut/TurnCutSlot.cs index dad1cf6a..96139246 100644 --- a/src/AgentCore.Application/Runtime/TurnCutSlot.cs +++ b/src/AgentCore.Application/Runtime/Cut/TurnCutSlot.cs @@ -1,8 +1,8 @@ -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Cut { /// /// 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 turn's commit. /// /// The conversation's turn lock. /// The host's token, which the run's own token is linked to. diff --git a/src/AgentCore.Application/Runtime/Harness/ApprovalAnswerHold.cs b/src/AgentCore.Application/Runtime/Harness/ApprovalAnswerHold.cs new file mode 100644 index 00000000..e51b643b --- /dev/null +++ b/src/AgentCore.Application/Runtime/Harness/ApprovalAnswerHold.cs @@ -0,0 +1,95 @@ +using System.Runtime.CompilerServices; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Runtime.Harness +{ + /// + /// Holds a person's approval answers back until every request of the round has one, so a round that asked for + /// two or more approvals can be answered one request at a time. + /// + internal static class ApprovalAnswerHold + { + /// Adds the layer. It goes directly around the agent, inside MAF's approval layer when there is one. + internal static AIAgentBuilder Use(AIAgentBuilder builder) + { + return builder.Use( + runFunc: static (messages, session, options, inner, cancellationToken) => RunAsync(messages, session, options, inner, cancellationToken), + runStreamingFunc: static (messages, session, options, inner, cancellationToken) => StreamAsync(messages, session, options, inner, cancellationToken)); + } + + private static async Task RunAsync( + IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent inner, CancellationToken cancellationToken) + { + List input = [.. messages]; + List open = Take(input, session, options); + return open.Count > 0 + ? new AgentResponse(new ChatMessage(ChatRole.Assistant, open)) + : await inner.RunAsync(input, session, options, cancellationToken).ConfigureAwait(false); + } + + private static async IAsyncEnumerable StreamAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent inner, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + List input = [.. messages]; + List open = Take(input, session, options); + if (open.Count > 0) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, open); + yield break; + } + + await foreach (AgentResponseUpdate update in inner.RunStreamingAsync(input, session, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + /// + /// Takes in the run's answers. Returns the requests still open when some are, after holding the answers; + /// returns nothing when the run may go on, after noting every refused call and dropping what was held. + /// + private static List Take(List input, AgentSession? session, AgentRunOptions? options) + { + List answers = [.. input.SelectMany(static message => message.Contents).OfType()]; + if (session is null || answers.Count == 0) + { + return []; + } + + IReadOnlyList queued = PendingApprovalQueue.Requests(session); + List held = PendingApprovalQueue.Held(session); + HashSet answered = new(held.Concat(answers).Select(static answer => answer.RequestId), StringComparer.Ordinal); + List open = [.. queued.Where(request => !answered.Contains(request.RequestId))]; + TurnInvocation? turn = TurnInvocation.From(options); + + if (open.Count == 0) + { + foreach (ToolApprovalResponseContent refused in held.Concat(answers).Where(static answer => !answer.Approved)) + { + turn?.Results?.NoteDenied(refused.ToolCall.CallId); + } + + PendingApprovalQueue.Hold(session, []); + return []; + } + + HashSet asked = new(queued.Select(static request => request.RequestId), StringComparer.Ordinal); + HashSet kept = new(held.Select(static answer => answer.RequestId), StringComparer.Ordinal); + held.AddRange(answers.Where(answer => asked.Contains(answer.RequestId) && kept.Add(answer.RequestId))); + PendingApprovalQueue.Hold(session, held); + + foreach (ToolApprovalRequestContent request in open) + { + turn?.Results?.NoteReshown(request.ToolCall.CallId); + } + + return [.. open]; + } + } +} diff --git a/src/AgentCore.Application/Runtime/Harness/ApprovalQueueDrain.cs b/src/AgentCore.Application/Runtime/Harness/ApprovalQueueDrain.cs new file mode 100644 index 00000000..38db814e --- /dev/null +++ b/src/AgentCore.Application/Runtime/Harness/ApprovalQueueDrain.cs @@ -0,0 +1,108 @@ +using System.Runtime.CompilerServices; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime.Harness +{ + /// + /// Lets a message that answers requests and carries new words reach the model through MAF's approval layer. + /// + internal static class ApprovalQueueDrain + { + /// Adds the layer. It goes directly outside UseToolApproval. + internal static AIAgentBuilder Use(AIAgentBuilder builder) + { + return builder.Use( + runFunc: static (messages, session, options, inner, cancellationToken) => RunAsync(messages, session, options, inner, cancellationToken), + runStreamingFunc: static (messages, session, options, inner, cancellationToken) => StreamAsync(messages, session, options, inner, cancellationToken)); + } + + private static async Task RunAsync( + IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent inner, CancellationToken cancellationToken) + { + List input = [.. messages]; + (HashSet answered, List words) = Split(input); + + for (int pass = 0; ; pass++) + { + AgentResponse response = await inner.RunAsync(input, session, options, cancellationToken).ConfigureAwait(false); + if (pass >= answered.Count || !OnlyAnswered(response.Messages.SelectMany(static message => message.Contents), answered)) + { + return response; + } + + input = words; + } + } + + private static async IAsyncEnumerable StreamAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent inner, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + List input = [.. messages]; + (HashSet answered, List words) = Split(input); + + for (int pass = 0; ; pass++) + { + List held = []; + bool spoke = false; + await foreach (AgentResponseUpdate update in inner.RunStreamingAsync(input, session, options, cancellationToken).ConfigureAwait(false)) + { + if (update.Contents.OfType().Any()) + { + held.Add(update); + continue; + } + + spoke = spoke || update.Contents.Any(static content => content is not TextContent { Text.Length: 0 }); + yield return update; + } + + if (spoke || pass >= answered.Count || !OnlyAnswered(held.SelectMany(static update => update.Contents), answered)) + { + foreach (AgentResponseUpdate kept in held) + { + yield return kept; + } + + yield break; + } + + input = words; + } + } + + /// The request ids the message answers, and the message without its answers; no answer or no words, no replay. + private static (HashSet Answered, List Words) Split(List input) + { + HashSet answered = new( + input.SelectMany(static message => message.Contents).OfType().Select(static answer => answer.RequestId), + StringComparer.Ordinal); + + List words = []; + foreach (ChatMessage message in input) + { + List rest = [.. message.Contents.Where(static content => content is not ToolApprovalResponseContent)]; + if (rest.Count > 0) + { + ChatMessage kept = message.Clone(); + kept.Contents = rest; + words.Add(kept); + } + } + + return words.Count == 0 ? ([], words) : (answered, words); + } + + /// Whether the run only handed back requests the message already answered. + private static bool OnlyAnswered(IEnumerable contents, HashSet answered) + { + List said = [.. contents.Where(static content => content is not TextContent { Text.Length: 0 })]; + return said.Count > 0 + && said.All(content => content is ToolApprovalRequestContent request && answered.Contains(request.RequestId)); + } + } +} diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs index a48d3207..1d4cb431 100644 --- a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs +++ b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs @@ -37,8 +37,8 @@ protected override async ValueTask CreateSessionCoreAsync(Cancella } /// - /// The part of the parent's turn a child may share: the conversation, its workspace folder, and its - /// shells, so files: and shell: on the child work on the same disk. Every drain, + /// The part of the parent's turn a child may share: the conversation, its workspace folder, its shells, so + /// files: and shell: on the child work on the same disk, and the end backstop and count of its tools. Every drain, /// tool list, and clarification stays with the parent; a child's publish reports a /// link in its result instead. /// @@ -53,6 +53,8 @@ private static TurnInvocation Shared(TurnInvocation parent) Shells = parent.Shells, Knowledge = parent.Knowledge, TimeZone = parent.TimeZone, + ToolStop = parent.ToolStop, + ToolRuns = parent.ToolRuns, Nested = true, }; } diff --git a/src/AgentCore.Application/Runtime/Harness/CallerTimeZone.cs b/src/AgentCore.Application/Runtime/Harness/CallerTimeZone.cs index 8797d5a4..30aad9f4 100644 --- a/src/AgentCore.Application/Runtime/Harness/CallerTimeZone.cs +++ b/src/AgentCore.Application/Runtime/Harness/CallerTimeZone.cs @@ -1,4 +1,5 @@ using Microsoft.Agents.AI; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Runtime.Harness { diff --git a/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs b/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs index 6fc62af8..1012cab9 100644 --- a/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs +++ b/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using AgentCore.Application.Transcript; using AgentCore.Domain; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace AgentCore.Application.Runtime.Harness @@ -7,8 +9,7 @@ namespace AgentCore.Application.Runtime.Harness /// /// The framework's pending approval queue, read off a session's state bag. The queue is a JSON /// array of {toolCall: {name, arguments, conversationId}, requiresConfirmation, requestId} under - /// — a MAF-internal shape pinned by probe - /// docs/probes/harness-agentic/approval-roundtrip/, not a contract. Every read here is + /// — a MAF-internal shape, not a contract. Every read here is /// lenient: an entry that lost its shape is skipped, never thrown, because the turn must end /// however the queue looks. /// @@ -20,6 +21,85 @@ internal static class PendingApprovalQueue /// What the approval layer names its standing and auto rules in the state bag. internal const string StandingStateKey = "toolApprovalState"; + /// What AgentCore names the answers it holds until every queued request has one. + internal const string HeldStateKey = "agentcoreHeldApprovalAnswers"; + + /// Reads the queued requests off a live session. + /// The conversation's session. + /// The requests, oldest first. The list may be the bag's own copy, so the caller must not change it. + internal static IReadOnlyList Requests(AgentSession session) + { + return session.StateBag.TryGetValue(PendingStateKey, out List? queue, TranscriptJson.Options) && queue is not null + ? queue + : []; + } + + /// + /// Reads the queued requests no answer covers yet: none AgentCore holds, and none MAF's approval layer + /// already collected in its own state, where it keeps the answers to a request it showed one at a time. + /// + /// The conversation's session. + /// The open requests, oldest first; empty when nothing waits on the caller. + internal static List Open(AgentSession session) + { + IReadOnlyList queued = Requests(session); + if (queued.Count == 0) + { + return []; + } + + HashSet answered = new(Held(session).Select(static answer => answer.RequestId), StringComparer.Ordinal); + answered.UnionWith(Collected(session.StateBag.Serialize())); + return [.. queued.Where(request => !answered.Contains(request.RequestId))]; + } + + /// Reads the answers AgentCore holds back off a live session. + /// The conversation's session. + /// The held answers, oldest first. + internal static List Held(AgentSession session) + { + return session.StateBag.TryGetValue(HeldStateKey, out List? held, TranscriptJson.Options) && held is not null + ? [.. held] + : []; + } + + /// Keeps the held answers on a live session, or drops the key when none is left. + /// The conversation's session. + /// The answers to keep. + internal static void Hold(AgentSession session, List held) + { + if (held.Count == 0) + { + _ = session.StateBag.TryRemoveValue(HeldStateKey); + return; + } + + session.StateBag.SetValue(HeldStateKey, held, TranscriptJson.Options); + } + + /// Drops withdrawn requests from the queue, with any answer AgentCore holds for them. + /// The conversation's session. + /// The ids of the requests an edit withdrew. + internal static void Withdraw(AgentSession session, IReadOnlySet withdrawn) + { + if (withdrawn.Count == 0) + { + return; + } + + List kept = [.. Requests(session).Where(request => !withdrawn.Contains(request.RequestId))]; + if (kept.Count == 0) + { + _ = session.StateBag.TryRemoveValue(PendingStateKey); + } + else + { + session.StateBag.SetValue(PendingStateKey, kept, TranscriptJson.Options); + } + + Hold(session, [.. Held(session).Where(answer => !withdrawn.Contains(answer.RequestId))]); + } + /// Reads the queue out of a serialized state bag. /// What AgentSessionStateBag.Serialize produced. /// The pending approvals, oldest first; empty when the bag carries no queue. @@ -63,11 +143,15 @@ internal static IReadOnlyList Read(JsonElement bag) /// What AgentSessionStateBag.Serialize produced. /// The id the caller answers. /// Whether the tool may run. - /// The user message carrying the approval response, or when no queued request carries that id. + /// + /// The user message carrying the approval response, or when no queued request carries that id + /// or its answer is already held. + /// internal static ChatMessage? AnswerFor(JsonElement bag, string requestId, bool approved) { if (!bag.TryGetProperty(PendingStateKey, out JsonElement queue) - || queue.ValueKind != JsonValueKind.Array) + || queue.ValueKind != JsonValueKind.Array + || IsHeld(bag, requestId)) { return null; } @@ -103,6 +187,38 @@ internal static IReadOnlyList Read(JsonElement bag) return null; } + // The approval layer's state is MAF-internal: {collectedApprovalResponses: [{requestId, ...}], ...}. + private static IEnumerable Collected(JsonElement bag) + { + if (!bag.TryGetProperty(StandingStateKey, out JsonElement state) + || state.ValueKind != JsonValueKind.Object + || !state.TryGetProperty("collectedApprovalResponses", out JsonElement collected) + || collected.ValueKind != JsonValueKind.Array) + { + yield break; + } + + foreach (JsonElement answer in collected.EnumerateArray()) + { + if (answer.ValueKind == JsonValueKind.Object + && answer.TryGetProperty("requestId", out JsonElement id) + && id.ValueKind == JsonValueKind.String) + { + yield return id.GetString()!; + } + } + } + + private static bool IsHeld(JsonElement bag, string requestId) + { + return bag.TryGetProperty(HeldStateKey, out JsonElement held) + && held.ValueKind == JsonValueKind.Array + && held.EnumerateArray().Any(answer => answer.ValueKind == JsonValueKind.Object + && answer.TryGetProperty("requestId", out JsonElement id) + && id.ValueKind == JsonValueKind.String + && id.GetString() == requestId); + } + private static object? ValueOf(JsonElement element) { return element.ValueKind switch diff --git a/src/AgentCore.Application/Runtime/AgentCoreAgentSession.cs b/src/AgentCore.Application/Runtime/Session/AgentCoreAgentSession.cs similarity index 97% rename from src/AgentCore.Application/Runtime/AgentCoreAgentSession.cs rename to src/AgentCore.Application/Runtime/Session/AgentCoreAgentSession.cs index 9e2233b1..4b9b8dfa 100644 --- a/src/AgentCore.Application/Runtime/AgentCoreAgentSession.cs +++ b/src/AgentCore.Application/Runtime/Session/AgentCoreAgentSession.cs @@ -1,7 +1,7 @@ using AgentCore.Application.Ports; using Microsoft.Agents.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { /// One conversation, as the framework sees it: the session creates. internal sealed class AgentCoreAgentSession : AgentSession diff --git a/src/AgentCore.Application/Runtime/ConversationBusyMark.cs b/src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs similarity index 93% rename from src/AgentCore.Application/Runtime/ConversationBusyMark.cs rename to src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs index c624f36c..14f2a963 100644 --- a/src/AgentCore.Application/Runtime/ConversationBusyMark.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs @@ -1,7 +1,9 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Turn.Lifecycle; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { /// /// The conversation's busy mark in the store. @@ -19,7 +21,7 @@ internal sealed class ConversationBusyMark /// /// 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. + /// , and the of a holder that crashed. /// internal static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(15); @@ -122,7 +124,7 @@ internal async ValueTask EnterRequestAsync(long started, CancellationToken cance } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone); + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusal.Gone); throw; } catch (OperationCanceledException) @@ -205,7 +207,7 @@ private async Task TakeAsync(long started, CancellationToken cancellationToken) } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone); + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusal.Gone); throw; } } @@ -215,7 +217,7 @@ private async Task TakeAsync(long started, CancellationToken cancellationToken) /// The exception to throw. internal static ConversationTurnConflictException Refuse(ConversationSession session) { - TurnRefusals.Raise(session, turnIndex: null, TurnRefusals.Busy); + TurnRefusals.Raise(session, turnIndex: null, TurnRefusal.Busy); return new ConversationTurnConflictException( $"Another request still held conversation '{session.ConversationId}' after {WaitLimit.TotalSeconds:0} s, " + "so this turn was refused before it ran."); @@ -234,6 +236,7 @@ private async Task MarkAsync(CancellationToken cancellationToken) #pragma warning restore CA1031 { Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception); + SessionFaults.Raise(_session, FaultKind.BusyMarkFailed, turnIndex: null, $"{exception.GetType().Name}: {exception.Message}", exception); return true; } } @@ -257,6 +260,8 @@ private void Renew() .ConfigureAwait(false)) { Log.BusyMarkLost(_session.Logger, _session.ConversationId); + SessionFaults.Raise( + _session, FaultKind.BusyMarkLost, turnIndex: null, "the busy mark lapsed while a turn still ran, and another session took it.", cause: null); } } #pragma warning disable CA1031 // A renewal that fails is retried by the next tick, and the mark lapses at worst. @@ -264,6 +269,7 @@ private void Renew() #pragma warning restore CA1031 { Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception); + SessionFaults.Raise(_session, FaultKind.BusyMarkFailed, turnIndex: null, $"{exception.GetType().Name}: {exception.Message}", exception); } return true; diff --git a/src/AgentCore.Application/Runtime/Session/ConversationCleanup.cs b/src/AgentCore.Application/Runtime/Session/ConversationCleanup.cs new file mode 100644 index 00000000..b7052b1d --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/ConversationCleanup.cs @@ -0,0 +1,67 @@ +namespace AgentCore.Application.Runtime.Session +{ + /// + /// Disposes a conversation's shells and deletes its workspace once no tool of the conversation still runs in them, + /// or once the end backstop stopped those tools (). A tool keeps its + /// folder through its grace: a cut, a withdraw or the end never takes the disk from under a call that started. + /// + /// The conversation. + internal sealed class ConversationCleanup(ConversationSession session) + { + private readonly Lock _gate = new(); + + private Task _pending = Task.CompletedTask; + + /// Gets what completes once every cleanup asked for so far is done. + internal Task Pending + { + get + { + lock (_gate) + { + return _pending; + } + } + } + + /// Cleans up now when no tool runs, and otherwise once the running tools ended or were stopped. + /// Whether the shells go too, and not only the workspace. + /// Completes once the cleanup is done, or once it is left to run after the tools. + internal Task AfterToolsAsync(bool shells) + { + if (session.ToolRuns.IsIdle) + { + return CleanAsync(shells); + } + + Task later = LaterAsync(shells); + lock (_gate) + { + _pending = Task.WhenAll(_pending, later); + } + + return Task.CompletedTask; + } + + private async Task LaterAsync(bool shells) + { + _ = await Task.WhenAny(session.ToolRuns.WhenIdle(), session.Lifetime.Ending.WhenToolsStopped).ConfigureAwait(false); + await CleanAsync(shells).ConfigureAwait(false); + } + + private async Task CleanAsync(bool shells) + { + try + { + if (shells) + { + await session.Lifetime.DisposeShellsAsync().ConfigureAwait(false); + } + } + finally + { + session.Lifetime.DeleteWorkspace(); + } + } + } +} diff --git a/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs b/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs new file mode 100644 index 00000000..635fa158 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs @@ -0,0 +1,346 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Runtime.Session +{ + /// + /// Raises once, after the turn that was running when the end was asked for. + /// It never cancels that turn: a tool may end the conversation from inside it. + /// + internal sealed class ConversationEnding(ConversationSession session) : IDisposable + { + /// How long an end waits for the running turn to seal. + internal static readonly TimeSpan TurnWait = TimeSpan.FromSeconds(5); + + /// + /// How long a tool still running when the conversation ends, or its session is disposed at shutdown, may go on + /// before it is cancelled. A cut or a withdraw never cancels a running tool; this bounds only + /// the end. Shell tasks take about 40 s, so this is not LiveKit's 5 s. + /// + internal static readonly TimeSpan ToolGrace = TimeSpan.FromSeconds(30); + + private readonly CancellationTokenSource _toolStop = new(); + + private readonly TaskCompletionSource _toolsStopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly Lock _toolGate = new(); + + private ITimer? _toolStopTimer; + + private bool _disposed; + + // Guarded by the session's turn lock, as the turn slot is: an end waits only while its turn holds the slot, + // and the turn that frees the slot raises it in the same step, before anything waiting on that turn resumes. + private (ConversationEndReason Reason, int Turn)? _pending; + + private readonly TaskCompletionSource _whenRequested = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly Lock _callGate = new(); + + private int _requested; + + private CallMark? _call; + + private bool _callLeft; + + private EndAsk? _ask; + + // Guarded by the session's turn lock, as Raise is. + private bool _raised; + + private readonly List _afterEnd = []; + + /// + /// Gets whether the end was asked for, by the host, a tool, or a terminal stage. It is set at once, while + /// may still wait for the running turn, so refusals and cuts read this. + /// + internal bool Requested => Volatile.Read(ref _requested) == 1; + + /// Gets a task that completes once is set, however the end was asked for. + internal Task WhenRequested => _whenRequested.Task; + + /// Gets the token every running tool of this conversation reads. It is cancelled after the end. + internal CancellationToken ToolStop => _toolStop.Token; + + /// Gets a task that completes once the backstop cancelled . + internal Task WhenToolsStopped => _toolsStopped.Task; + + /// Starts the of the tools still running. Only the first call starts it. + internal void StopToolsAfterGrace() + { + lock (_toolGate) + { + if (_toolStopTimer is null && !_disposed) + { + _toolStopTimer = session.Time.CreateTimer(static state => ((ConversationEnding)state!).StopTools(), this, ToolGrace, Timeout.InfiniteTimeSpan); + } + } + } + + /// + public void Dispose() + { + lock (_toolGate) + { + _disposed = true; + _toolStopTimer?.Dispose(); + } + + _toolStop.Dispose(); + } + + // The timer can fire while the session is disposed; a disposed source has no tool left to stop. + private void StopTools() + { + try + { + _toolStop.Cancel(); + } + catch (ObjectDisposedException) + { + } + finally + { + _ = _toolsStopped.TrySetResult(); + } + } + + /// Gets whether a phone call holds this session, taken by the transport or only admitted. + internal bool HasCall => Volatile.Read(ref _call) is not null; + + /// + /// Holds this session for one admitted call, so no second call joins it (one live call per conversation). A + /// newer call with the id of the call that holds the session takes it over, as LiveKit lets the newest + /// connection of one identity stay: the older call's is run, after the claim. + /// + /// The transport's id of the call. + /// Names the claiming call, and is run if a newer call with its id takes the session over. + /// When the call that was taken over started, so its length runs on; otherwise . + /// + /// when a call with another id holds the session, or when the conversation is leaving: + /// its call let go of it, or its end was asked for. + /// + internal bool ClaimCall(string callId, Action holder, out DateTimeOffset? keptStart) + { + ArgumentException.ThrowIfNullOrEmpty(callId); + ArgumentNullException.ThrowIfNull(holder); + CallMark? replaced; + lock (_callGate) + { + replaced = _call; + if (replaced is not null && (replaced.CallId != callId || _callLeft || Requested)) + { + keptStart = null; + return false; + } + + _call = new CallMark(callId, replaced?.StartedAt, holder); + } + + keptStart = replaced?.StartedAt; + replaced?.Holder(); + return true; + } + + /// + /// Marks this session as carrying a phone call, so every it raises names the + /// call. Only a session no call holds, or one this same call holds, takes the mark. + /// + /// The transport's id of the call. + /// Names the call, as in . + /// When the transport took the call, on the session's clock. + /// when another call holds the session; its mark is left as it was. + internal bool TryMarkCall(string callId, Action holder, DateTimeOffset startedAt) + { + ArgumentException.ThrowIfNullOrEmpty(callId); + ArgumentNullException.ThrowIfNull(holder); + lock (_callGate) + { + if (_call is { } held && !ReferenceEquals(held.Holder, holder)) + { + return false; + } + + _call = new CallMark(callId, startedAt, holder); + return true; + } + } + + /// + /// The call that holds this session lets go of it, to end or close it: from now on no newer call takes it + /// over. Calling it again is harmless. + /// + /// Names the call, as in . + /// + /// when a newer call took the session over from this one: the session, its end and its + /// brief are that call's now. + /// + internal bool ReleaseCall(Action holder) + { + lock (_callGate) + { + if (_call is { } held && !ReferenceEquals(held.Holder, holder)) + { + return false; + } + + _callLeft = true; + return true; + } + } + + /// Ends the conversation now, or right after the running turn. Only the first end counts. + /// Why the conversation ended. + /// The transport's word for why its call ended, or . Kept only for the first end. + /// when this call is the first end of the session. + internal bool Request(ConversationEndReason reason, string? cause = null) + { + if (Interlocked.Exchange(ref _requested, 1) == 1) + { + return false; + } + + _ = _whenRequested.TrySetResult(); + StopToolsAfterGrace(); + + Volatile.Write(ref _ask, new EndAsk(cause, session.Time.GetUtcNow())); + + bool waits = false; + lock (session.TurnLock) + { + if (session.Cuts.RunningTurnIndex() is int running) + { + _pending = (reason, running); + waits = true; + } + } + + if (waits) + { + _ = RaiseAfterWaitAsync(); + return true; + } + + Raise(reason, terminalStage: null, lastAdmittedTurn: session.State.TurnIndex - 1); + return true; + } + + /// + /// Raises a notice that must follow : at once when no end is waiting to be raised, + /// or right after that end when one is (it may wait on the running turn). + /// + /// The notice, a call's . + internal void RaiseAfterEnd(HookNotice notice) + { + lock (session.TurnLock) + { + if (Requested && !_raised) + { + _afterEnd.Add(notice); + return; + } + + _ = session.Hooks.Raise(notice); + } + } + + /// + /// Marks the end of a conversation an earlier session already ended, as the state restored says. Its + /// is in the chain already, so nothing is raised, and a notice + /// that must follow it goes out at once. + /// + internal void Restored() + { + lock (session.TurnLock) + { + Volatile.Write(ref _requested, 1); + _raised = true; + } + + _ = _whenRequested.TrySetResult(); + } + + /// Called by the seal after the turn's : raises a pending end, or the terminal one. + internal void AfterTurn(int turnIndex, string stageAfter, bool complete) + { + if (TakePending() is { } pending) + { + Raise(pending.Reason, terminalStage: null, turnIndex); + } + else if (complete && Interlocked.Exchange(ref _requested, 1) == 0) + { + _ = _whenRequested.TrySetResult(); + Raise(ConversationEndReason.AgentCompleted, stageAfter, turnIndex); + } + } + + /// + /// Raises an end still waiting on its turn. The cut tracker calls this as the turn frees its slot, under the + /// turn lock: a turn the store refused, or one dropped unread, never reaches . + /// + internal void RaisePending() + { + lock (session.TurnLock) + { + if (TakePending() is { } pending) + { + Raise(pending.Reason, terminalStage: null, pending.Turn); + } + } + } + + private async Task RaiseAfterWaitAsync() + { + await Task.Delay(TurnWait, session.Time).ConfigureAwait(false); + RaisePending(); + } + + private (ConversationEndReason Reason, int Turn)? TakePending() + { + lock (session.TurnLock) + { + (ConversationEndReason Reason, int Turn)? pending = _pending; + _pending = null; + return pending; + } + } + + private void Raise(ConversationEndReason reason, string? terminalStage, int lastAdmittedTurn) + { + SessionHooks hooks = session.Hooks; + lock (session.TurnLock) + { + hooks.EnsureStarted(); + _ = hooks.RaiseEnd( + new ConversationEnded(hooks.Scope(turnIndex: null, terminalStage), reason, terminalStage, Call: CallEndNow()), + lastAdmittedTurn); + _raised = true; + foreach (HookNotice after in _afterEnd) + { + _ = hooks.Raise(after); + } + + _afterEnd.Clear(); + } + } + + // A call's length runs to when its end was asked for: the raise may wait up to TurnWait on the running turn. + private CallEnd? CallEndNow() + { + if (Volatile.Read(ref _call) is not { StartedAt: { } startedAt } call) + { + return null; + } + + EndAsk? ask = Volatile.Read(ref _ask); + DateTimeOffset endedAt = ask?.At ?? session.Time.GetUtcNow(); + return new CallEnd(call.CallId, (endedAt - startedAt).TotalSeconds, ask?.Cause); + } + + private sealed record CallMark(string CallId, DateTimeOffset? StartedAt, Action Holder); + + private sealed record EndAsk(string? Cause, DateTimeOffset At); + } +} diff --git a/src/AgentCore.Application/Runtime/ConversationSession.cs b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs similarity index 81% rename from src/AgentCore.Application/Runtime/ConversationSession.cs rename to src/AgentCore.Application/Runtime/Session/ConversationSession.cs index 325f6490..c3caa162 100644 --- a/src/AgentCore.Application/Runtime/ConversationSession.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs @@ -3,6 +3,8 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Policy; using AgentCore.Application.Ports; using AgentCore.Application.Runtime.Harness; @@ -15,8 +17,14 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; - -namespace AgentCore.Application.Runtime +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Runtime.Session { /// /// The turn loop of one conversation. It owns the state, the stage machine, and the transcript. @@ -28,43 +36,6 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable /// public const string FallbackReply = AgentCoreConfiguration.DefaultFallbackReply; - /// - /// The reason a turn that produced no text reports. - /// - public const string EmptyReplyReason = "the run returned an empty reply, so the turn spoke the fallback."; - - /// - /// The reason a turn that lost its tool budget reports, before the message of the fault. - /// - 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. - /// - internal const string ExtractionTimedOutReason = "the turn completion passed its deadline."; - - /// - /// What the log records when the moderation endpoint runs out of time. - /// - internal const string ModerationTimedOutReason = "the moderation endpoint passed its deadline."; - - /// - /// What the log records when the moderation endpoint throws. - /// - internal const string ModerationFaultedReason = "the moderation endpoint threw."; - - /// - /// How long the work after the reply may take before it is abandoned. - /// - internal static readonly TimeSpan TurnCompletionTimeout = TimeSpan.FromSeconds(5); - internal StagePolicy? Policy { get; } internal StateExtractor? Extractor { get; } @@ -73,8 +44,6 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal TimeProvider Time { get; } - internal ConversationEventChain Events { get; } - internal DateTimeOffset StartedAt { get; } internal AgentCoreChatHistoryProvider History { get; } @@ -129,19 +98,22 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal ConversationBusyMark Busy { get; } + internal ConversationToolRuns ToolRuns { get; } + + internal FinishedToolPairs ToolPairs { get; } + /// /// Creates the session of one conversation. /// internal ConversationSession( string conversationId, ConversationSessionSeams seams, - ConversationObserverDispatcher? observers = null, ConversationWorkspace? workspace = null) { ArgumentException.ThrowIfNullOrEmpty(conversationId); ArgumentNullException.ThrowIfNull(seams); - (CompiledAgent? compiled, IGuardEvaluator? guards, StateExtractor? extractor, TimeProvider? timeProvider, ILogger? logger) = seams; + (CompiledAgent? compiled, IGuardEvaluator? guards, StateExtractor? extractor, TimeProvider? timeProvider, ILogger? logger, HookRuntime? hooks) = seams; ConversationId = conversationId; @@ -163,7 +135,7 @@ internal ConversationSession( Time = timeProvider; - Events = new ConversationEventChain(conversationId, observers ?? new ConversationObserverDispatcher([]), timeProvider); + Hooks = new SessionHooks(hooks, conversationId, compiled.EntryName, timeProvider); StartedAt = timeProvider.GetUtcNow(); @@ -173,8 +145,6 @@ internal ConversationSession( _ = ConstStateWriter.Apply(State); - _ = Events.Raise(ConversationEventKind.ConversationStarted, StartedAt, turnIndex: null); - Runner = new ConversationTurnRunner(this); Stream = new ConversationTurnStream(this); @@ -190,6 +160,10 @@ internal ConversationSession( Lifetime = new ConversationSessionLifetime(this); Busy = new ConversationBusyMark(this); + + ToolPairs = new FinishedToolPairs(this); + + ToolRuns = new ConversationToolRuns(ToolPairs.Keep); } /// @@ -197,9 +171,12 @@ internal ConversationSession( /// public string ConversationId { get; } + internal SessionHooks Hooks { get; } + /// /// Gets the folder this conversation owns on disk, or when the host bound no - /// workspace root. The folder is deleted when the conversation ends. + /// workspace root. The folder is deleted once the conversation ended and no tool still runs in it, at most + /// 30 s after the end. /// public string? Workspace => WorkspaceRoot?.Path; @@ -373,7 +350,7 @@ public IAsyncEnumerable RunTurnMessageStreamingAtOriginAsync public Task StartTurnAsync( ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken = default) { - return Stream.StartTurnAsync(userInput, origin, cancellationToken); + return Stream.StartTurnAsync(userInput, origin, before: [], cancellationToken); } /// @@ -388,7 +365,7 @@ public bool Recut(int turnIndex, TurnCut cut) return Cuts.Recut(turnIndex, cut); } - /// Closes the conversation, and writes the last event of its chain. + /// Ends the conversation. is raised now, or right after the running turn. public bool EndConversation(ConversationEndReason reason) { return Lifetime.EndConversation(reason); @@ -404,7 +381,7 @@ public ValueTask DisposeAsync() } /// - /// Waits for every store 1 write this conversation has queued. + /// Waits for every message store write this conversation has queued. /// public Task FlushTranscriptAsync() { @@ -412,20 +389,12 @@ public Task FlushTranscriptAsync() } /// - /// 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. + /// Waits until every notice this conversation raised so far was handled by every hook, or abandoned at + /// the hook's notice timeout. /// - internal ConversationSessionState Snapshot() - { - return States.Snapshot(); - } - - /// Names the state this conversation resumes from when store 0 holds none of its own. - internal void Resume(ConversationSessionState stored) + public Task FlushNoticesAsync() { - States.Resume(stored); + return Hooks.FlushAsync(); } } } diff --git a/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs b/src/AgentCore.Application/Runtime/Session/ConversationSessionFactory.cs similarity index 55% rename from src/AgentCore.Application/Runtime/ConversationSessionFactory.cs rename to src/AgentCore.Application/Runtime/Session/ConversationSessionFactory.cs index dc396d89..98f6a9da 100644 --- a/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationSessionFactory.cs @@ -1,47 +1,61 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Ports; using AgentCore.Application.State; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.ToolCalls; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { /// /// Builds one for each conversation, over one compiled agent. /// - public sealed class ConversationSessionFactory : IConversationSessionFactory + public sealed class ConversationSessionFactory : IConversationSessionFactory, IAsyncDisposable { private readonly ConversationSessionSeams _seams; - private readonly IConversationObserver[] _observers; - private readonly string? _workspaceRoot; + private readonly HookRuntime? _ownedHooks; + /// /// Creates the factory. When is bound, every conversation this factory /// builds gets its own folder under it; unbound, a session it builds has no workspace. /// + /// The compiled entry every session runs. + /// The evaluator that runs each exit guard. + /// The state extractor the document declares, or . + /// The clock every turn reads, or for the system clock. + /// Where the sessions' own log events go, or for none. + /// + /// Notice hooks of this factory's sessions, heard after the hooks the agent was compiled with, or + /// . A hook that overrides a gate must be compiled in (). + /// A factory with hooks of its own has its own notice delivery: its sessions count + /// per conversation id apart from the compiled hooks' count, so a conversation id that sessions of both open + /// gets two separate sequences. + /// + /// The root every conversation's folder is made under, or for no workspace. + /// A hook in overrides a gate and was not compiled in. public ConversationSessionFactory( CompiledAgent compiled, IGuardEvaluator guards, StateExtractor? extractor = null, TimeProvider? timeProvider = null, ILogger? logger = null, - IEnumerable? observers = null, + IEnumerable? hooks = null, string? workspaceRoot = null) { ArgumentNullException.ThrowIfNull(compiled); ArgumentNullException.ThrowIfNull(guards); - _seams = new ConversationSessionSeams(compiled, guards, extractor, timeProvider ?? TimeProvider.System, logger); + _ownedHooks = hooks is null ? null : compiled.Hooks.WithNoticeHooks(hooks); + _seams = new ConversationSessionSeams( + compiled, guards, extractor, timeProvider ?? TimeProvider.System, logger, _ownedHooks ?? compiled.Hooks); _workspaceRoot = workspaceRoot; - - // Copied, not held: the list is the caller's, and a caller that keeps adding to it after this - // must not change what a session already built. The order is the caller's too — see - // ConversationObservers.Standard, which is where the cost of that order is argued. - _observers = observers is null ? [] : [.. observers]; } /// Builds the extractor one document declares. @@ -64,6 +78,15 @@ public ConversationSessionFactory( : null; } + /// + /// Stops the notice delivery this factory started for its own hooks, waiting for the queued notices. A factory + /// built without hooks owns nothing and stops nothing: the compile's hooks belong to the host. + /// + public ValueTask DisposeAsync() + { + return _ownedHooks?.DisposeAsync() ?? ValueTask.CompletedTask; + } + /// public ConversationSession Create(string? conversationId = null, ConversationSessionState? state = null) { @@ -73,17 +96,12 @@ public ConversationSession Create(string? conversationId = null, ConversationSes ? null : ConversationWorkspace.Create(_workspaceRoot, resolvedConversationId, _seams.Time, _seams.Logger); - ConversationSession session = new( - resolvedConversationId, - _seams, - new ConversationObserverDispatcher(_observers, _seams.Logger), - workspace); + ConversationSession session = new(resolvedConversationId, _seams, workspace); - // Named, not applied. The session resumes on its first turn, where store 0's own copy - // outranks this one — see the remarks on ConversationSession.Resume. + // Named, not applied. The session resumes on its first turn, where the stored copy outranks this one. if (state is not null) { - session.Resume(state); + session.States.Resume(state); } return session; diff --git a/src/AgentCore.Application/Runtime/Session/ConversationSessionLifetime.cs b/src/AgentCore.Application/Runtime/Session/ConversationSessionLifetime.cs new file mode 100644 index 00000000..f03e5692 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/ConversationSessionLifetime.cs @@ -0,0 +1,319 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime.Harness; +using AgentCore.Domain.Audit; +using Microsoft.Agents.AI; + +namespace AgentCore.Application.Runtime.Session +{ + internal sealed class ConversationSessionLifetime + { + private readonly ConversationSession _session; + + private readonly Lock _replacedGate = new(); + + private int _backgroundReleased; + + private volatile bool _owned; + + // Guarded by the session's turn lock. + private bool _endUnsaved; + + private Task _replacedReleases = Task.CompletedTask; + + private Task _storedEnd = Task.CompletedTask; + + internal ConversationSessionLifetime(ConversationSession session) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + Ending = new ConversationEnding(session); + Cleanup = new ConversationCleanup(session); + } + + /// + /// Marks this session as held by a session owner, which unloads it with . + /// A plain then leaves the mailbox pin to that unload. + /// + internal void MarkOwned() + { + _owned = true; + } + + /// Gets what raises this conversation's ConversationEnded. + internal ConversationEnding Ending { get; } + + /// Gets what deletes this conversation's workspace, and disposes its shells, after its tools. + internal ConversationCleanup Cleanup { get; } + + /// Closes the conversation, and asks for . + /// Why the conversation ended, as one member of the closed set. + /// The transport's word for why its call ended, or . + /// + /// when this call was the first end of the conversation, and + /// when the conversation had already ended. + /// + /// + /// is not a member of the closed set. + /// + internal bool EndConversation(ConversationEndReason reason, string? cause = null) + { + // The token is read first, so a value outside the closed set ends nothing. + _ = ConversationEndReasons.ToToken(reason); + + // Complete before the end reads the turn slot: under the turn lock, a turn that takes the slot after + // that read sees it and is refused, and one that took it before is the turn the end waits for. + _session.IsComplete = true; + bool first = Ending.Request(reason, cause); + SaveEnd(); + + _ = Cleanup.AfterToolsAsync(shells: false); + return first; + } + + /// + /// Stores that the conversation is over, so a session that loads it later refuses its turns. A running turn + /// may have read its state before the end, so the write waits for that turn to free its slot and then lands + /// behind the turn's own append (). + /// + private void SaveEnd() + { + lock (_session.TurnLock) + { + if (_session.Cuts.RunningTurnIndex() is not null) + { + _endUnsaved = true; + return; + } + + SaveState(); + } + } + + /// Writes an end that waited on the turn now freeing its slot. The cut tracker calls this under the turn lock. + internal void SaveEndAfterTurn() + { + if (_endUnsaved) + { + _endUnsaved = false; + SaveState(); + } + } + + /// Gets what completes once an end written while no session of this conversation was open landed. + internal Task StoredEnd => Volatile.Read(ref _storedEnd); + + private void SaveState() + { + if (_session.Ledger.Session() is { } session) + { + _session.History.SaveState(session, _session.States.Snapshot()); + return; + } + + Volatile.Write(ref _storedEnd, SaveStoredEndAsync()); + } + + // No turn of this session opened the conversation, so the end amends the state the conversation store holds: the stage, the + // slots and the turn index of the session that last ran a turn stay as they were. + private async Task SaveStoredEndAsync() + { + IConversationStore store = _session.Compiled.ConversationStore; + try + { + if (await store.GetAsync(_session.ConversationId).ConfigureAwait(false) is { } record) + { + ConversationSessionState basis = record.State ?? _session.States.Snapshot(); + await store.SaveStateAsync(_session.ConversationId, basis with { IsComplete = true }).ConfigureAwait(false); + } + } +#pragma warning disable CA1031 // Nothing awaits this write but a flush or a dispose, and a refused write never ends anything. + catch (Exception exception) +#pragma warning restore CA1031 + { + Log.TranscriptWriteFailed(_session.Logger, _session.ConversationId, _session.State.TurnIndex, exception); + } + } + + /// + /// Deletes this conversation's workspace, if it has one. Called by once the conversation + /// ended and its tools are done, and by the session owner after it closed or unloaded the session. + /// + internal void DeleteWorkspace() + { + _session.WorkspaceRoot?.Delete(_session.Logger); + } + + /// + /// 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, waits for the release of every session a catch-up replaced and for its + /// running tools (at most ), and disposes its shell executors. + /// Idempotent: a session already disposed, or one that never had either, disposes nothing. + /// + internal ValueTask DisposeAsync() + { + return DisposeAsync(unloaded: null); + } + + /// + /// Disposes as does. For a session its owner unloads, it then raises + /// , last, whether or not a step threw, while the session still pins its + /// mailbox. An end that waited on the last turn was raised when that turn freed its slot, so it comes first. + /// + /// Why the owner unloads the session, or for a plain dispose. + internal async ValueTask DisposeAsync(UnloadCause? unloaded) + { + try + { + Ending.StopToolsAfterGrace(); + await _session.Cuts.CloseTurnsAsync().ConfigureAwait(false); + await ReleaseBackgroundSessionsAsync().ConfigureAwait(false); + await ReplacedReleases().ConfigureAwait(false); + _ = await Task.WhenAny(_session.ToolRuns.WhenIdle(), Ending.WhenToolsStopped).ConfigureAwait(false); + await DisposeShellsAsync().ConfigureAwait(false); + await Cleanup.Pending.ConfigureAwait(false); + await StoredEnd.ConfigureAwait(false); + } + finally + { + if (unloaded is { } cause) + { + SessionHooks hooks = _session.Hooks; + _ = hooks.Raise(new ConversationUnloaded(hooks.Scope(turnIndex: null, stage: null), cause)); + } + + // A host may dispose a session its owner still holds; the owner's unload then comes later, inside the pin. + if (unloaded is not null || !_owned) + { + _session.Hooks.Release(); + Ending.Dispose(); + } + } + } + + /// + /// 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 + /// children. Release precedes the shells: a child cancelled here may be inside a shell tool. + /// + internal async ValueTask ReleaseBackgroundSessionsAsync() + { + if (Interlocked.Exchange(ref _backgroundReleased, 1) == 1 + || _session.Compiled.BackgroundProviders.Count == 0 + || _session.Ledger.Session() is not { } session) + { + return; + } + + await BackgroundSessionRelease.ReleaseAsync( + _session.Compiled.BackgroundProviders, session, _session.ConversationId, _session.Logger, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Disposes this conversation's shell executors, if it has any. does not conversation + /// this: a tool that ends the conversation mid-turn still needs the shell for the rest of that turn, so + /// only the turn-end path and tear it down. + /// + 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). + /// +#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/ConversationSessionSeams.cs b/src/AgentCore.Application/Runtime/Session/ConversationSessionSeams.cs similarity index 79% rename from src/AgentCore.Application/Runtime/ConversationSessionSeams.cs rename to src/AgentCore.Application/Runtime/Session/ConversationSessionSeams.cs index 3471278e..8cd8fc41 100644 --- a/src/AgentCore.Application/Runtime/ConversationSessionSeams.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationSessionSeams.cs @@ -1,9 +1,10 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.State; using Microsoft.Extensions.Logging; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { /// What every session one factory builds shares: the entry, and the seams it runs on. /// The compiled entry every conversation runs. @@ -11,10 +12,12 @@ namespace AgentCore.Application.Runtime /// The state extractor the document declares, or . /// The clock every turn reads. /// Where the session's own log events go, or for none. + /// The hooks every session of this factory raises its notices to. internal sealed record ConversationSessionSeams( CompiledAgent Compiled, IGuardEvaluator Guards, StateExtractor? Extractor, TimeProvider Time, - ILogger? Logger); + ILogger? Logger, + HookRuntime Hooks); } diff --git a/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs b/src/AgentCore.Application/Runtime/Session/ConversationSessionStateStore.cs similarity index 88% rename from src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs rename to src/AgentCore.Application/Runtime/Session/ConversationSessionStateStore.cs index 9a363330..83ebe5d9 100644 --- a/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationSessionStateStore.cs @@ -1,10 +1,11 @@ using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Conversation; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.State; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { internal sealed class ConversationSessionStateStore { @@ -45,7 +46,12 @@ internal void Restore(ConversationSessionState stored) _session.State.Stage = stored.Stage; - _session.IsComplete = stored.IsComplete; + // An end this session already took stands, whatever an older stored state says. + _session.IsComplete = stored.IsComplete || _session.Lifetime.Ending.Requested; + if (stored.IsComplete) + { + _session.Lifetime.Ending.Restored(); + } } else { @@ -76,14 +82,7 @@ internal void Restore(ConversationSessionState stored) void Dropped(string reason) { - _session.Events.RaiseDiagnostic( - ConversationEventKind.StateRestorePartial, - _session.Time.GetUtcNow(), - turnIndex: null, - payload: new Dictionary(StringComparer.Ordinal) - { - [ConversationEventPayloadKeys.Reason] = reason, - }); + SessionFaults.Raise(_session, FaultKind.StateRestorePartial, turnIndex: null, reason, cause: null); } } @@ -91,7 +90,7 @@ void Dropped(string reason) /// 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. + /// The state the conversation store holds, newer than this session's. internal void CatchUp(ConversationSessionState stored) { if (stored.Version == ConversationSessionState.CurrentVersion) @@ -131,7 +130,7 @@ internal ConversationSessionState Snapshot() } } - /// Names the state this conversation resumes from when store 0 holds none of its own. + /// Names the state this conversation resumes from when the conversation store holds none of its own. internal void Resume(ConversationSessionState stored) { ArgumentNullException.ThrowIfNull(stored); diff --git a/src/AgentCore.Application/Runtime/ConversationStoreReads.cs b/src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs similarity index 85% rename from src/AgentCore.Application/Runtime/ConversationStoreReads.cs rename to src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs index b9c2731b..0eb67d4d 100644 --- a/src/AgentCore.Application/Runtime/ConversationStoreReads.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs @@ -1,12 +1,14 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { /// - /// Reads what store 1 holds beyond a session's words, and counts the session's writes the store did not take, so a + /// Reads what the message store 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 @@ -31,7 +33,7 @@ internal ConversationStoreReads(ConversationSession session) /// 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. + /// refusal: nothing else holds its ids, and the conversation store outranks a filed session on resume. /// internal bool Refused => _losses.Refused; @@ -45,7 +47,7 @@ internal void MarkRefused() public void Dropped(int turnIndex, IReadOnlyList appended, Exception exception) { _losses.Lose(appended, exception is ConversationTurnConflictException); - _session.Events.RaiseDroppedTranscriptWrite(turnIndex, exception); + SessionFaults.Raise(_session, FaultKind.TranscriptWriteFailed, turnIndex, $"{exception.GetType().Name}: {exception.Message}", exception); } /// Records that the session took what read, and every loss counted before that read. @@ -80,7 +82,7 @@ public void Realigned(TranscriptLossVerdict verdict) /// /// The session of this conversation. /// Cancels the reads. - /// What store 1 holds, or when it holds nothing the session lacks, or cannot be read. + /// What the message store 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); @@ -114,16 +116,16 @@ public void Realigned(TranscriptLossVerdict verdict) 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); + SessionFaults.Raise( + _session, FaultKind.TranscriptResyncFailed, _session.State.TurnIndex, $"{exception.GetType().Name}: {exception.Message}", 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. + /// What the message store read for the session. + /// The next ordinal by the conversation store's own counter. /// The next ordinal as the session counted it. /// Cancels the reads. private async ValueTask OvertakenAsync( @@ -168,13 +170,13 @@ private async ValueTask LandedAsync(IReadOnlyList held, HashSetStore 0's record of this conversation, which must exist for a session that already opened it. + /// The conversation store'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."); + $"The conversation store 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/Session/ConversationTranscriptLedger.cs similarity index 86% rename from src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs rename to src/AgentCore.Application/Runtime/Session/ConversationTranscriptLedger.cs index b8294e4e..e5061117 100644 --- a/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationTranscriptLedger.cs @@ -1,10 +1,12 @@ using AgentCore.Application.Conversation; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { internal sealed class ConversationTranscriptLedger { @@ -17,17 +19,17 @@ internal ConversationTranscriptLedger(ConversationSession 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. + /// Gets the reads of what the message store holds beyond the session's words, and the count of its lost writes. internal ConversationStoreReads Reads { get; } /// - /// Opens the session for one turn, and reads what store 1 holds beyond its words. Changes no words the + /// Opens the session for one turn, and reads what the message store 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. + /// for a session opened just now, or one whose words the message store already matches. /// internal async ValueTask<(AgentSession Session, TranscriptCatchUp? CatchUp)> OpenForTurnAsync(CancellationToken cancellationToken) { @@ -40,7 +42,7 @@ internal ConversationTranscriptLedger(ConversationSession session) } /// - /// Brings an admitted turn's session in line with what store 1 held. A read made before the turn was admitted + /// Brings an admitted turn's session in line with what the message store 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. /// @@ -81,11 +83,11 @@ private async ValueTask BringInAsync(AgentSession opened, TranscriptCatchUp catc } /// - /// Takes the words store 1 holds, with the turn index, the stage, the slots, and the harness providers' state or + /// Takes the words the message store 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. + /// What the message store 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. @@ -123,12 +125,12 @@ private async ValueTask TakeStoredAsync( } /// - /// Replaces the session with one read from the state another session stored, holding the words store 1 read, + /// Replaces the session with one read from the state another session stored, holding the words the message store read, /// and releases the old one. /// /// The session of this conversation. - /// What store 1 held. - /// The state store 0 holds. + /// What the message store held. + /// The state the conversation store 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( @@ -165,11 +167,11 @@ private async ValueTask RebuildAsync( } /// - /// Moves the turn index on past the turns store 1 holds, and takes the stage and slots another session stored. + /// Moves the turn index on past the turns the message store 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. + /// What the message store held. /// The state to take, or when the session keeps its own. private void TakeState(TranscriptCatchUp catchUp, ConversationSessionState? stored) { @@ -233,10 +235,26 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel Reads, new TranscriptMarks(record.NextOrdinal, stored?.NextTurnIndex ?? 0)); + SessionHooks hooks = _session.Hooks; + ConversationOrigin origin = hooks.OriginOf(storeHeldConversation: record.LastMessageAt is not null || stored is not null); + + // No audit row follows conversation.ended, so a session that loads an ended + // conversation does not start it again, unless the restore refused the stored end. + bool storedEnded = stored is { IsComplete: true }; + if (!storedEnded) + { + hooks.RaiseStarted(origin); + } + if (stored is { } s) { _session.States.Restore(s); } + + if (storedEnded && !_session.IsComplete) + { + hooks.RaiseStarted(origin); + } } return _session.AgentSession; @@ -282,6 +300,8 @@ internal async Task FlushTranscriptAsync() { await _session.History.DrainAsync(session).ConfigureAwait(false); } + + await _session.Lifetime.StoredEnd.ConfigureAwait(false); } /// Builds the answer message for one queued approval request of this conversation. @@ -300,7 +320,7 @@ internal async Task FlushTranscriptAsync() : null; } - /// The session a graph row's conversation holds, so store 1 has somewhere to live. + /// The session a graph row's conversation holds, so the message store has somewhere to live. private sealed class ConversationHistorySession : AgentSession; } } diff --git a/src/AgentCore.Application/Runtime/ConversationWorkspace.cs b/src/AgentCore.Application/Runtime/Session/ConversationWorkspace.cs similarity index 99% rename from src/AgentCore.Application/Runtime/ConversationWorkspace.cs rename to src/AgentCore.Application/Runtime/Session/ConversationWorkspace.cs index 868c934c..6063a68e 100644 --- a/src/AgentCore.Application/Runtime/ConversationWorkspace.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationWorkspace.cs @@ -1,7 +1,7 @@ using AgentCore.Application.Diagnostics; using Microsoft.Extensions.Logging; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Session { /// /// The folder one conversation owns on disk: created with the session, and deleted when the session closes, unloads, or ends. diff --git a/src/AgentCore.Application/Runtime/Session/SessionFaults.cs b/src/AgentCore.Application/Runtime/Session/SessionFaults.cs new file mode 100644 index 00000000..22feb0fb --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/SessionFaults.cs @@ -0,0 +1,15 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Runtime.Session +{ + /// Raises the non-fatal faults of one session. + internal static class SessionFaults + { + internal static void Raise(ConversationSession session, FaultKind kind, int? turnIndex, string message, Exception? cause) + { + SessionHooks hooks = session.Hooks; + _ = hooks.Raise(new Fault(hooks.Scope(turnIndex, stage: null), kind, message, cause)); + } + } +} diff --git a/src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs b/src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs similarity index 52% rename from src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs rename to src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs index 56e085c5..431b3587 100644 --- a/src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs @@ -3,41 +3,23 @@ using System.Security.Authentication; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tools; -using AgentCore.Domain.Audit; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.ToolCalls { - /// One tool call that did not run to completion, as the function-invocation loop saw it. - internal sealed record ToolFailure - { - /// Gets the name the MODEL called. - - public required string ToolName { get; init; } - - /// Gets the id the model gave this one call. - public required string ToolCallId { get; init; } - - /// Gets which of the two ways a tool call fails this one was. - public required ToolFailureKind Kind { get; init; } - - /// Gets what went wrong, in one sentence. It never holds a secret value. - public required string Message { get; init; } - } - /// - /// The function-invocation loop of Microsoft.Extensions.AI, with every tool call that did not - /// run to completion reported to the call that made it, and the tool error policy applied to every - /// call this loop makes. + /// The function-invocation loop of Microsoft.Extensions.AI, with the tool error policy applied to every + /// call this loop makes: a fault the model can answer becomes the error result it reads, and a fault beyond the + /// model is marked with its call id and rethrown. reports each call. /// internal sealed class AuditingFunctionInvokingChatClient : FunctionInvokingChatClient { private readonly ConcurrentDictionary _drains = new(StringComparer.Ordinal); - private sealed record Drain( - IReadOnlyList Attachments, - Action? OnToolFailure, - bool Nested) + private sealed record Drain(IReadOnlyList Attachments, bool Nested, TurnCutSlot? Turn, ConversationToolRuns? Runs) { /// Takes what one call produced, kind by kind, in the turn's own order. public IEnumerable TakeFor(string callId) @@ -66,8 +48,8 @@ internal void Release(IReadOnlyList callIds) } /// - /// Runs one tool, turns a fault the model can answer into the error result it reads, and reports - /// and rethrows a fault beyond the model. + /// Runs one tool, turns a fault the model can answer into the error result it reads, and marks and + /// rethrows a fault beyond the model. /// protected override async ValueTask InvokeFunctionAsync( FunctionInvocationContext context, @@ -87,53 +69,115 @@ internal void Release(IReadOnlyList callIds) nested = own.Nested; } + Drain? drain = null; if (invocation is not null) { // Snapshot the turn once per tool call and file it beside the call's arguments, so tools // declare what they need as parameters. _ = invocation.FileIn(context.Arguments); - _drains[callId] = new Drain(invocation.Attachments(), invocation.OnToolFailure, nested); + drain = new Drain(invocation.Attachments(), nested, invocation.CutSlot, invocation.ToolRuns); + _drains[callId] = drain; invocation.Results?.NoteCall(callId, Release); } - using OuterCall? outerCall = invocation is not null && !nested - ? OuterCall.Open(_drains[callId].Attachments, invocation.OuterCallId ?? callId) - : null; + if (cancellationToken.IsCancellationRequested) + { + return ToolErrorResult.Create(context.Function.Name, "the turn was stopped before this call ran."); + } + + // A background child's run carries no turn on its options; its session names the turn that started it. + TurnInvocation? owner = invocation ?? TurnRegistry.For(AIAgent.CurrentRunContext?.Session); + using CancellationTokenSource? linked = ToolToken(owner, cancellationToken, out CancellationToken toolToken); + if (owner?.ToolRuns is not { } runs) + { + return await InvokeAndRecordAsync(context, invocation, toolToken).ConfigureAwait(false); + } + + ToolRun run = runs.Start(context.CallContent, () => InvokeAndRecordAsync(context, invocation, toolToken).AsTask()); + if (invocation is not { Nested: false, CutSlot: { } turn }) + { + return await run.Running.ConfigureAwait(false); + } + + IEnumerable Attachments() => drain?.TakeFor(callId) ?? []; + object? result; + try + { + result = await run.Running.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (runs.Carry(run, Attachments, turn)) + { + throw; + } + + result = await run.Running.ConfigureAwait(false); + } + + runs.Returned(run, result, Attachments, turn); + return result; + } + private async ValueTask InvokeAndRecordAsync(FunctionInvocationContext context, TurnInvocation? invocation, CancellationToken toolToken) + { try { - object? result = await base.InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); + object? result = await base.InvokeFunctionAsync(context, toolToken).ConfigureAwait(false); - if (!nested) + if (invocation is { Nested: false }) { - invocation?.Results?.Record(context.Function.Name, result); + invocation.Results?.Record(context.Function.Name, result); } return result; } - catch (Exception failure) when (!IsCallerCancellation(failure, cancellationToken) - && !IsBeyondTheModel(failure)) + catch (Exception failure) when (!IsBeyondTheModel(failure)) { return ToolErrorResult.Create(context.Function.Name, failure.GetType().Name + ": " + failure.Message); } - catch (Exception failure) when (!IsCallerCancellation(failure, cancellationToken)) + catch (Exception failure) { - ToolFaultMark.Put(failure, callId); - - invocation?.OnToolFailure?.Invoke(new ToolFailure - { - ToolName = context.CallContent.Name, - ToolCallId = context.CallContent.CallId, - Kind = ToolFailureKind.Faulted, - Message = failure.GetType().Name + ": " + failure.Message, - }); + ToolFaultMark.Put(failure, context.CallContent.CallId); throw; } } + /// + /// Picks the token one tool call runs under. A conversation turn's call reads only the end backstop: the turn's + /// own token is cancelled by every cut and withdraw. Any other call reads its caller's token, + /// linked to the end backstop of the conversation it runs for, if any, such as a background: child's. + /// + /// The linked source the caller disposes after the call, or when none was made. + private static CancellationTokenSource? ToolToken(TurnInvocation? owner, CancellationToken caller, out CancellationToken toolToken) + { + if (owner is { CutSlot: not null }) + { + toolToken = owner.ToolStop; + return null; + } + + CancellationToken backstop = owner?.ToolStop ?? CancellationToken.None; + if (!backstop.CanBeCanceled || backstop == caller) + { + toolToken = caller; + return null; + } + + if (!caller.CanBeCanceled) + { + toolToken = backstop; + return null; + } + + CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(caller, backstop); + toolToken = linked.Token; + return linked; + } + /// Reports whether a fault is one the model cannot possibly answer. - private static bool IsBeyondTheModel(Exception failure) + internal static bool IsBeyondTheModel(Exception failure) { return failure switch { @@ -159,24 +203,18 @@ private static bool IsBeyondTheModel(Exception failure) // is a fact the model holds, and retrying a rejected token only rate-limits us. UnauthorizedAccessException or AuthenticationException => true, - // The caller's own cancellation never reaches here — the first catch filter tests the token - // first — so what is left is somebody else's deadline. HttpClient reports its own that way: a - // TaskCanceledException wrapping a TimeoutException, on a token nobody cancelled. + // A turn's running tool is never handed the turn's token, so this is a deadline, the end backstop, or the + // cancel of a run outside a turn. HttpClient reports its own deadline that way: a TaskCanceledException + // wrapping a TimeoutException. OperationCanceledException => true, _ => false, }; } - /// Reports whether a fault is the caller hanging up rather than the tool failing. - private static bool IsCallerCancellation(Exception failure, CancellationToken cancellationToken) - { - return failure is OperationCanceledException && cancellationToken.IsCancellationRequested; - } - /// - /// Builds the messages the model reads, reports every tool it could not find, and attaches - /// whatever this turn cited or published to the tool-result message it belongs to. + /// Builds the messages the model reads, and attaches whatever this turn cited or published to the + /// tool-result message it belongs to. /// protected override IList CreateResponseMessages(ReadOnlySpan results) { @@ -195,7 +233,17 @@ private void Attach(IList contents) { foreach (string? callId in contents.OfType().Select(r => r.CallId).ToList()) { - if (_drains.TryRemove(callId, out Drain? drain) && !drain.Nested) + if (!_drains.TryRemove(callId, out Drain? drain)) + { + continue; + } + + if (drain is { Turn: { } turn, Runs: { } runs }) + { + runs.Delivered(turn, callId); + } + + if (!drain.Nested) { foreach (AIContent attached in drain.TakeFor(callId)) { diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs b/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs new file mode 100644 index 00000000..5e0f427d --- /dev/null +++ b/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs @@ -0,0 +1,242 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; + +namespace AgentCore.Application.Runtime.ToolCalls +{ + /// + /// The tool calls running on one conversation. A cut never waits for a started call: the call + /// runs on, and once it has a result, its call and result go to , which stores them in the + /// conversation's history, so no later turn runs the tool again. The calls of its round that already returned their + /// results are kept with it (). A call that ends with no result, as one the end + /// backstop stops, leaves nothing to keep. + /// + /// Stores the call and the result of a finished call its turn no longer waited for. + internal sealed class ConversationToolRuns(Action> keep) + { + private readonly Lock _gate = new(); + + private int _running; + + private TaskCompletionSource? _idle; + + private int _carried; + + private TaskCompletionSource? _carriedDone; + + private readonly ConditionalWeakTable _returned = []; + + /// Gets whether no tool call of this conversation is running. + internal bool IsIdle + { + get + { + lock (_gate) + { + return _running == 0; + } + } + } + + /// Starts one tool call and counts it until it ends. + /// The call the model made. + /// Runs the call. + /// The running call. + internal ToolRun Start(FunctionCallContent call, Func> invoke) + { + ArgumentNullException.ThrowIfNull(call); + ArgumentNullException.ThrowIfNull(invoke); + + ToolRun run = new(call); + lock (_gate) + { + _running++; + } + + run.Running = WatchAsync(run, invoke); + return run; + } + + /// + /// Lets a call its turn no longer waits for run on: its call and result are kept once it finished, and the calls + /// of its round that already returned are kept now. + /// + /// The call. + /// Reads what the call cited or published, once it finished. + /// The turn that made the call. + /// when the call already finished, so its turn takes the result itself. + internal bool Carry(ToolRun run, Func> attachments, TurnCutSlot turn) + { + ArgumentNullException.ThrowIfNull(run); + ArgumentNullException.ThrowIfNull(attachments); + ArgumentNullException.ThrowIfNull(turn); + + List>> siblings; + lock (_gate) + { + if (run.Done) + { + return false; + } + + run.Attachments = attachments; + _carried++; + siblings = _returned.GetOrCreateValue(turn).Break(); + } + + foreach (Func> sibling in siblings) + { + keep(sibling()); + } + + // Nothing awaits a carried call, so its fault is observed here; the tool hook already reported it. + _ = run.Running.ContinueWith( + static task => task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return true; + } + + /// + /// Notes a call whose result went back to its turn: kept now when another call of the turn was carried, else held + /// until a response message carries it (). + /// + /// The call. + /// What the call returned. + /// Reads what the call cited or published. + /// The turn that made the call. + internal void Returned(ToolRun run, object? result, Func> attachments, TurnCutSlot turn) + { + ArgumentNullException.ThrowIfNull(run); + ArgumentNullException.ThrowIfNull(attachments); + ArgumentNullException.ThrowIfNull(turn); + + IReadOnlyList Pair() => PairOf(run.Call, result, attachments()); + bool now; + lock (_gate) + { + now = _returned.GetOrCreateValue(turn).Add(run.Call.CallId, Pair); + } + + if (now) + { + keep(Pair()); + } + } + + /// Forgets a returned call once a response message of its turn carries its result. + /// The turn that made the call. + /// The call. + internal void Delivered(TurnCutSlot turn, string callId) + { + lock (_gate) + { + if (_returned.TryGetValue(turn, out ReturnedToolCalls? returned)) + { + returned.Delivered(callId); + } + } + } + + /// Completes once no tool call of this conversation is running. + internal Task WhenIdle() + { + lock (_gate) + { + return _running == 0 + ? Task.CompletedTask + : (_idle ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + } + + /// + /// Completes once no call that let run on is still running. By then each one's call and result + /// was kept, so a turn that starts after it reads them. + /// + internal Task WhenCarriedDone() + { + lock (_gate) + { + return _carried == 0 + ? Task.CompletedTask + : (_carriedDone ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + } + + // The pair is kept before the call is counted out, so a caller that saw the conversation idle finds it stored. + private async Task WatchAsync(ToolRun run, Func> invoke) + { + object? result = null; + bool answered = false; + try + { + result = await invoke().ConfigureAwait(false); + answered = true; + return result; + } + finally + { + Func>? carried; + lock (_gate) + { + run.Done = true; + carried = run.Attachments; + } + + try + { + if (answered && carried is not null) + { + keep(PairOf(run.Call, result, carried())); + } + } + finally + { + if (carried is not null) + { + CarriedOut(); + } + + CountOut(); + } + } + } + + private static IReadOnlyList PairOf(FunctionCallContent call, object? result, IEnumerable attachments) => + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(call.CallId, call.Name, call.Arguments)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent(call.CallId, result), .. attachments]), + ]; + + private void CarriedOut() + { + TaskCompletionSource? done = null; + lock (_gate) + { + if (--_carried == 0) + { + done = _carriedDone; + _carriedDone = null; + } + } + + _ = done?.TrySetResult(); + } + + private void CountOut() + { + TaskCompletionSource? idle = null; + lock (_gate) + { + if (--_running == 0) + { + idle = _idle; + _idle = null; + } + } + + _ = idle?.TrySetResult(); + } + } +} diff --git a/src/AgentCore.Application/Runtime/ToolCalls/FinishedToolPairs.cs b/src/AgentCore.Application/Runtime/ToolCalls/FinishedToolPairs.cs new file mode 100644 index 00000000..692393b7 --- /dev/null +++ b/src/AgentCore.Application/Runtime/ToolCalls/FinishedToolPairs.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Runtime.ToolCalls +{ + /// + /// Stores the call and result of a tool a cut or withdrawn turn left running, as rows of the conversation, the + /// moment the tool finished: they survive a reload, and a refused next turn, and every later turn reads them as + /// history. A turn that holds the slot already read the history its request carries, so the rows wait for it to + /// free the slot and land behind its own rows. + /// + /// The conversation. + internal sealed class FinishedToolPairs(ConversationSession session) + { + // Guarded by the session's turn lock. + private readonly List _waiting = []; + + /// Stores one finished call and its result, now or after the running turn. + internal void Keep(IReadOnlyList pair) + { + lock (session.TurnLock) + { + if (session.Cuts.RunningTurnIndex() is not null) + { + _waiting.AddRange(pair); + return; + } + + Write(pair); + } + } + + /// Stores what waited on the turn now freeing its slot. The cut tracker calls this under the turn lock. + internal void AfterTurn() + { + if (_waiting.Count == 0) + { + return; + } + + List waiting = [.. _waiting]; + _waiting.Clear(); + Write(waiting); + } + + // The rows take the turn the conversation takes next, as a row a host appends between turns does. + private void Write(IReadOnlyList messages) + { + if (session.Ledger.Session() is { } opened) + { + session.History.AppendBetweenTurns(opened, messages, session.State.TurnIndex); + } + } + } +} diff --git a/src/AgentCore.Application/Runtime/ModelFacingChatClient.cs b/src/AgentCore.Application/Runtime/ToolCalls/ModelFacingChatClient.cs similarity index 96% rename from src/AgentCore.Application/Runtime/ModelFacingChatClient.cs rename to src/AgentCore.Application/Runtime/ToolCalls/ModelFacingChatClient.cs index 99b89971..d129ddb3 100644 --- a/src/AgentCore.Application/Runtime/ModelFacingChatClient.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/ModelFacingChatClient.cs @@ -1,8 +1,9 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Transcript; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.ToolCalls { /// Takes what only the caller can use out of the history before the model sees it. internal sealed class ModelFacingChatClient(IChatClient inner) : DelegatingChatClient(inner) diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ReturnedToolCalls.cs b/src/AgentCore.Application/Runtime/ToolCalls/ReturnedToolCalls.cs new file mode 100644 index 00000000..b5dea4a4 --- /dev/null +++ b/src/AgentCore.Application/Runtime/ToolCalls/ReturnedToolCalls.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime.ToolCalls +{ + /// + /// The calls of one turn whose results went back to the function-invocation loop but are in no response message + /// yet. Once a call of the turn is carried (), the cancel ends its round + /// before the round writes its response messages, so no turn would read these results: they are kept as the carried + /// call's is. Guarded by the lock of its . + /// + internal sealed class ReturnedToolCalls + { + private readonly Dictionary>> _returned = new(StringComparer.Ordinal); + + private bool _broken; + + /// Notes a call that returned its result. + /// when a call of the turn was carried already, so the pair is kept now. + internal bool Add(string callId, Func> pair) + { + if (_broken) + { + return true; + } + + _returned[callId] = pair; + return false; + } + + /// Forgets a call whose result a response message carries. + internal void Delivered(string callId) + { + _ = _returned.Remove(callId); + } + + /// Marks the turn as ended by the cancel. + /// The pairs of the calls that returned and reached no response message. + internal List>> Break() + { + _broken = true; + List>> taken = [.. _returned.Values]; + _returned.Clear(); + return taken; + } + } +} diff --git a/src/AgentCore.Application/Runtime/ToolCallScopes.cs b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs similarity index 87% rename from src/AgentCore.Application/Runtime/ToolCallScopes.cs rename to src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs index f4bc6b6c..af5d6133 100644 --- a/src/AgentCore.Application/Runtime/ToolCallScopes.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs @@ -1,6 +1,7 @@ using AgentCore.Application.Tools; +using AgentCore.Application.Runtime.Turn; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.ToolCalls { // The of one turn, built from the invocation the conversation runs under. internal static class ToolCallScopes @@ -19,7 +20,7 @@ internal static ToolCallScope From(TurnInvocation invocation) { ArgumentNullException.ThrowIfNull(invocation); - return new ToolCallScope(invocation.ConversationId, invocation.TurnIndex, invocation.Stage, invocation.Workspace); + return new ToolCallScope(invocation.ConversationId, invocation.TurnIndex, invocation.Stage, invocation.Workspace) { Items = invocation.Items }; } } } diff --git a/src/AgentCore.Application/Runtime/ToolFaultMark.cs b/src/AgentCore.Application/Runtime/ToolCalls/ToolFaultMark.cs similarity index 94% rename from src/AgentCore.Application/Runtime/ToolFaultMark.cs rename to src/AgentCore.Application/Runtime/ToolCalls/ToolFaultMark.cs index d7202d06..6529cb45 100644 --- a/src/AgentCore.Application/Runtime/ToolFaultMark.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/ToolFaultMark.cs @@ -1,4 +1,6 @@ -namespace AgentCore.Application.Runtime +using AgentCore.Application.Runtime.Agents; + +namespace AgentCore.Application.Runtime.ToolCalls { /// /// Marks a fault a tool threw, so the turn can tell a tool that spent its retry budget from a run that faulted diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ToolRun.cs b/src/AgentCore.Application/Runtime/ToolCalls/ToolRun.cs new file mode 100644 index 00000000..35cfb8b3 --- /dev/null +++ b/src/AgentCore.Application/Runtime/ToolCalls/ToolRun.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime.ToolCalls +{ + /// One tool call counts while it runs. + /// The call the model made. + internal sealed class ToolRun(FunctionCallContent call) + { + /// Gets the call the model made. + internal FunctionCallContent Call { get; } = call; + + /// Gets the call as it runs. It ends with the tool's result, or with its fault. + internal Task Running { get; set; } = Task.FromResult(null); + + /// Gets or sets whether the call ended. Guarded by the lock of its . + internal bool Done { get; set; } + + /// + /// Gets or sets what reads the call's citations and files once it finished, set when its turn stopped waiting for + /// it. Guarded by the lock of its . + /// + internal Func>? Attachments { get; set; } + } +} diff --git a/src/AgentCore.Application/Runtime/ConversationTurn.cs b/src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs similarity index 63% rename from src/AgentCore.Application/Runtime/ConversationTurn.cs rename to src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs index 5f73cd3e..1fedc51a 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurn.cs +++ b/src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs @@ -1,15 +1,18 @@ using System.Diagnostics; -using AgentCore.Application.Runtime.Turn; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn.Lifecycle; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn { // The turn's working set, carried from preparation through commit. Built once per turn in // ConversationTurnRunner.BeginTurn for the same reason Activity is: ConversationTurnStream reopens the state // scope on every step of a streaming turn, and collectors built per step would lose whatever an // earlier step cited or published before a later step could attach it to a message. + // Waiting holds the call ids of the approval requests an earlier turn already stored, read before the run + // consumes MAF's queue, so a request shown again is not stored twice. + // Before is what was said on a call ahead of Spoken that no turn answered (TurnCommit.Before). internal sealed record ConversationTurn( AIAgent Agent, AgentSession Session, @@ -23,5 +26,7 @@ internal sealed record ConversationTurn( TurnResults Results, TurnFiles Files, KnowledgeScope? Knowledge, - ConversationTurnOrigin? Origin); + ConversationTurnOrigin? Origin, + IReadOnlyCollection Waiting, + IReadOnlyList Before); } diff --git a/src/AgentCore.Application/Runtime/ConversationTurnOrigin.cs b/src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs similarity index 94% rename from src/AgentCore.Application/Runtime/ConversationTurnOrigin.cs rename to src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs index 2ee53613..2d6e8586 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnOrigin.cs +++ b/src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs @@ -1,4 +1,4 @@ -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn { /// Where a turn's words sit in the conversation the caller can see. /// diff --git a/src/AgentCore.Application/Runtime/Turn/FileNotice.cs b/src/AgentCore.Application/Runtime/Turn/FileNotice.cs new file mode 100644 index 00000000..d3b4be69 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Turn/FileNotice.cs @@ -0,0 +1,19 @@ +using AgentCore.Application.Transcript; + +namespace AgentCore.Application.Runtime.Turn +{ + /// + /// A file a graph participant published, on its way to the turn's caller around the workflow. The turn's stream + /// hands the caller the file itself. + /// + /// The file the store kept. + /// The participant that published it. + internal sealed class FileNotice(FileContent file, string? author) : NoticeContent + { + /// Gets the file the store kept. + public FileContent File { get; } = file; + + /// Gets the participant that published the file. + public string? Author { get; } = author; + } +} diff --git a/src/AgentCore.Application/Runtime/Turn/ITurnAttachments.cs b/src/AgentCore.Application/Runtime/Turn/ITurnAttachments.cs index 470cde1f..c8b7a02b 100644 --- a/src/AgentCore.Application/Runtime/Turn/ITurnAttachments.cs +++ b/src/AgentCore.Application/Runtime/Turn/ITurnAttachments.cs @@ -4,19 +4,10 @@ namespace AgentCore.Application.Runtime.Turn { /// /// One kind of thing a turn's tools produce for the caller, as the invoking client handles every - /// kind alike: opened around the outermost tool call, drained onto that call's result message. + /// kind alike: drained onto the result message of the outermost tool call it was filed under. /// - /// - /// A new kind implements this once and joins TurnInvocation.Attachments(). The invoking client - /// never names a kind. - /// internal interface ITurnAttachments { - /// Opens one outermost tool call as the key publishes file under. - /// The id of the outermost tool call now running. - /// The scope. Disposing it puts back the key that was open before. - IDisposable BeginOuterCall(string callId); - /// Takes what was filed under one outer tool call, in publish order. /// The call whose content to take. /// What that call filed, or empty. diff --git a/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs similarity index 81% rename from src/AgentCore.Application/Runtime/ConversationTurnAgent.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs index 7b0b6f9e..f9eb6ac2 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs @@ -1,11 +1,12 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// /// The outermost turn layer, and the one trigger of a turn's durable write. On entry it runs the turn's edit; @@ -13,10 +14,10 @@ namespace AgentCore.Application.Runtime /// 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. + /// The message store, 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). + /// The cut of a run the host cancelled or the reader abandoned: it keeps everything yielded. private static readonly TurnCut Stopped = new(ShownText: null, Played: null); private readonly AgentCoreChatHistoryProvider _history = history ?? throw new ArgumentNullException(nameof(history)); @@ -64,7 +65,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA yield break; } - IEnumerable request = await EnterAsync(turn, messages, cancellationToken).ConfigureAwait(false); + (IEnumerable request, IReadOnlyList carried) = await EnterAsync(turn, messages, cancellationToken).ConfigureAwait(false); List seen = []; Exception? fault = null; @@ -93,7 +94,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA hostCancelled = true; break; } -#pragma warning disable CA1031 // Section 8.7, row six: a fault above every fallback still ends in a sealed turn. +#pragma warning disable CA1031 // a fault above every fallback still ends in a sealed turn. catch (Exception exception) when (exception is not OperationCanceledException) #pragma warning restore CA1031 { @@ -105,15 +106,15 @@ protected override async IAsyncEnumerable RunCoreStreamingA yield return stream.Current; } - seal = await SealAsync(turn, seen, fault, hostCancelled ? Stopped : null).ConfigureAwait(false); + seal = await SealAsync(turn, carried, 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); + // yielded. A turn already sealed seals nothing here. + _ = await SealAsync(turn, carried, seen, fault, Stopped).ConfigureAwait(false); } // The host asked to stop: the turn is committed, and the host still gets its cancellation. @@ -150,11 +151,18 @@ protected override async IAsyncEnumerable RunCoreStreamingA } /// Runs the turn's edit, and builds the request the run gets. - private async ValueTask> EnterAsync( + /// + /// The request, and the tool calls and results of the turns the edit withdrew. The request carries them after the + /// user's message, so the model reads that those tools already ran. + /// + private async ValueTask<(IEnumerable Request, IReadOnlyList Carried)> EnterAsync( FiledTurn turn, IEnumerable messages, CancellationToken cancellationToken) { + IReadOnlyList carried = []; if (turn.Invocation.Origin is { NamesParent: true } origin) { + IReadOnlyList ran = TurnMessages.ToolPairs(_history.RowsAfter(turn.HistorySession, origin.ParentMessageId).Rows); + IReadOnlySet asked = TurnApprovalAnswers.Withdrawn(_history, turn.HistorySession, 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); @@ -162,17 +170,22 @@ private async ValueTask> EnterAsync( if (withdrawn is { } taken) { turn.Completer.Superseded(taken); + carried = ran; + PendingApprovalQueue.Withdraw(turn.HistorySession, asked); } } - return turn.Invocation.RendersHistory + IEnumerable request = turn.Invocation.RendersHistory && TurnMessages.GraphHistory(_history.Read(turn.HistorySession)) is { } rendered ? [rendered, .. messages] : messages; + + return (carried.Count == 0 ? request : [.. request, .. carried], carried); } /// Seals the turn: decide it, write it once, and raise its events. /// The turn being sealed. + /// The tool calls and results the turn's edit kept from the turns it withdrew. /// 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. @@ -181,7 +194,7 @@ private async ValueTask> EnterAsync( /// holds no conversation, and whether the store refused them. /// private async ValueTask SealAsync( - FiledTurn turn, List seen, Exception? fault, TurnCut? stopped) + FiledTurn turn, IReadOnlyList carried, List seen, Exception? fault, TurnCut? stopped) { if (!turn.CutSlot.TrySeal(out TurnCut? cut)) { @@ -196,7 +209,10 @@ private async ValueTask SealAsync( Disposition = ConversationTurnStream.ReadDisposition(seen), Cut = cut, CallerFacing = !turn.Invocation.CarriesHistory, + Files = turn.Invocation.Files?.Delivered ?? [], UserMessageId = turn.Invocation.Origin?.MessageId, + Carried = carried, + Before = turn.Invocation.Before, }; TurnCommit commit = await turn.Completer.CompleteAsync(sealing, fault).ConfigureAwait(false); diff --git a/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs similarity index 54% rename from src/AgentCore.Application/Runtime/ConversationTurnCommit.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs index 392d7db1..304819a5 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs @@ -1,8 +1,11 @@ using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Domain; -using AgentCore.Domain.Audit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// /// The publish half of a turn: writes the turn's events once its words are committed, and tears the @@ -39,38 +42,42 @@ internal async ValueTask RefusedAsync(Task refused) return true; } - /// Publishes one committed turn. Runs under the turn lock, right after the commit. + /// + /// Publishes one committed turn. Runs under the turn lock, right after the commit, so a turn the store refused + /// names no stage move. + /// /// 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 words the turn's rows say. /// 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's notice, raised after the turn's , if it moved the stage. /// 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) + TurnCut? late, + TurnCompleted completed) { _session.LastReplyMessageId = ids is { } written ? written.ReplyMessageId ?? written.UserMessageId : null; - if (outcome.Fault is { } fault && outcome.IsToolFault) + SessionHooks hooks = _session.Hooks; + if (!string.Equals(result.StageAfter, result.StageBefore, StringComparison.Ordinal)) { - Log.ToolBudgetSpent(_session.Logger, result.ConversationId, result.TurnIndex, fault); + _ = hooks.Raise(new StageChanged(hooks.Scope(result.TurnIndex, result.StageBefore), result.StageBefore, result.StageAfter), result.EndedAt); } - // 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.AmendableEventId = hooks.Raise(completed, result.EndedAt); + if (cut is not null) + { + RaiseCut(result.TurnIndex, spoken, cut.Played, result.EndedAt); + } - _session.AmendableEventId = completedEventId; _session.AmendableText = spoken; _session.LastTurn = result; @@ -85,25 +92,38 @@ internal TurnResult Publish( return result; } - /// Closes the chain of a conversation the turn left in a terminal stage, and releases what it held. + /// Raises the cut that amends the turn's . + /// The cut turn. + /// The words the caller heard. + /// How long the reply played, or . + /// The turn's end for a cut sealed with the turn, or for a later cut: now. + internal void RaiseCut(int turnIndex, string heard, TimeSpan? played, DateTimeOffset? occurredAt = null) + { + SessionHooks hooks = _session.Hooks; + _ = hooks.Raise(new ReplyCut(hooks.Scope(turnIndex, stage: null), heard, played, _session.AmendableEventId ?? Guid.Empty), occurredAt); + } + + /// + /// Raises the end a turn left pending or reached, including that 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) + /// The turn that just sealed. + internal async Task EndConversationIfOverAsync(string stageAfter, int turnIndex) { - if (_session.IsComplete) - { - _ = _session.Events.EndConversation(ConversationEndReason.AgentCompleted, _session.Time.GetUtcNow(), stageAfter); - } + _session.Lifetime.Ending.AfterTurn(turnIndex, stageAfter, _session.IsComplete); - if (!_session.IsComplete && !_session.Events.HasEnded) + if (!_session.IsComplete && !_session.Lifetime.Ending.Requested) { return; } - await _session.Lifetime.ReleaseBackgroundSessionsAsync().ConfigureAwait(false); + // A terminal stage ends the conversation as a host end does, so a tool a cut turn left running gets the same grace. + _session.Lifetime.Ending.StopToolsAfterGrace(); - await _session.Lifetime.DisposeShellsAsync().ConfigureAwait(false); + await _session.Lifetime.ReleaseBackgroundSessionsAsync().ConfigureAwait(false); - _session.Lifetime.DeleteWorkspace(); + await _session.Lifetime.Cleanup.AfterToolsAsync(shells: true).ConfigureAwait(false); } } } diff --git a/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs similarity index 70% rename from src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs index 30964b09..4d0e545c 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs @@ -1,13 +1,19 @@ using System.Diagnostics; using System.Globalization; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Transcript; using AgentCore.Domain; using AgentCore.Domain.Audit; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// /// The conversation's side of one turn's seal: it decides the turn after its run, and publishes it once @@ -27,6 +33,10 @@ internal sealed class ConversationTurnCompletion : ITurnCompleter private TurnCut? _cut; + private TurnDisposition? _disposition; + + private bool _escaped; + /// Creates the completion of one turn. /// The conversation. /// The turn it completes. @@ -47,17 +57,8 @@ 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), - }); + SessionHooks hooks = _session.Hooks; + _ = hooks.Raise(new TurnSuperseded(hooks.Scope(_session.State.TurnIndex, stage: null), withdrawn.First, withdrawn.Last)); } /// Runs every writer, then lets the machine pick the stage of the next turn. @@ -71,8 +72,7 @@ public void Superseded(WithdrawnTurns withdrawn) /// . /// /// - /// The fault that threw out of the run, or when nothing threw. Section 8.7, - /// row six. + /// The fault that threw out of the run, or when nothing threw. /// /// The commit, with the state after the stage advance and the line the record holds. public async ValueTask CompleteAsync(TurnCommit sealing, Exception? fault) @@ -87,15 +87,16 @@ public async ValueTask CompleteAsync(TurnCommit sealing, Exception? TurnDisposition? disposition = sealing.Disposition; + _disposition = disposition; + _escaped = fault is not null; + _cut = sealing.Cut; turn.Results.Sweep(); ReportUndeclaredToolCalls(turn, response); - bool refused = disposition?.Moderation is ModerationOutcome.Flagged; - - _session.Events.RaiseModeration(turn.Index, disposition); + bool refused = disposition?.Moderation is ModerationOutcome.Flagged || disposition?.Blocked == true; ReplyOutcome outcome = TurnReplyResolution.Resolve(_session, turn, response, _cut, disposition, fault, sealing.CallerFacing); _session.Writers.ApplyToolResults(response.Messages); @@ -138,7 +139,8 @@ public async ValueTask CompleteAsync(TurnCommit sealing, Exception? { Completed = outcome.InterruptedAfter is null && outcome.Failure is null, Reply = outcome.Reply, - State = _session.Snapshot(), + State = _session.States.Snapshot(), + Unasked = [.. turn.Results.DeniedCallIds, .. turn.Waiting, .. _session.Hooks.ApprovalDecisions.Where(static open => open.Value.Approved is false).Select(static open => open.Key)], }; } @@ -150,7 +152,19 @@ public async ValueTask RefusedAsync(Task refused) return false; } - TurnRefusals.Raise(_session, _turn.Index, TurnRefusals.Conflict); + TurnRefusals.Raise(_session, _turn.Index, TurnRefusal.Conflict); + + // A refused turn raises no TurnCompleted, so the hooks never see the failure the run ended on. + if (_outcome is { IsToolFault: false } refusedOutcome && (refusedOutcome.Fault is not null || refusedOutcome.Failure is not null)) + { + TurnFailureAccounting.Account( + _session.Logger, + _session.ConversationId, + _turn.Index, + refusedOutcome.Fault is null ? TurnOutcome.Empty : TurnOutcome.Faulted, + failedInTool: false, + refusedOutcome.Fault); + } AgentCoreTelemetry.EndTurn( _turn.Activity, @@ -172,7 +186,23 @@ public void Committed((string UserMessageId, string? ReplyMessageId)? ids, strin // 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); + Result = _commit.Publish(ids, spoken, decided, _cut, _cut is null ? late : recut, Completed(spoken, outcome, decided)); + } + + private TurnCompleted Completed(string spoken, ReplyOutcome outcome, TurnResult decided) + { + SessionHooks hooks = _session.Hooks; + return new TurnCompleted( + hooks.Scope(decided.TurnIndex, decided.StageBefore), + TurnOutcomes.Of(outcome, _disposition, _cut, _escaped), + _turn.Spoken.Text, + _cut is null ? spoken : outcome.GeneratedText, + decided.StageBefore, + decided.StageAfter, + _session.Time.GetElapsedTime(_turn.StartedAt), + decided.Failure, + outcome.IsToolFault, + outcome.Fault); } /// @@ -191,7 +221,7 @@ public async ValueTask FinishAsync() result.Failure, ErrorType(result.Failure)); - await _commit.EndConversationIfOverAsync(result.StageAfter).ConfigureAwait(false); + await _commit.EndConversationIfOverAsync(result.StageAfter, result.TurnIndex).ConfigureAwait(false); } /// Reads the error.type value a failed turn's span carries. The metric point keeps its outcome alone. @@ -218,26 +248,19 @@ public async ValueTask FinishAsync() { string? extractionFailure; - using CancellationTokenSource deadline = new(ConversationSession.TurnCompletionTimeout, _session.Time); + using CancellationTokenSource deadline = new(TurnFailureReasons.CompletionTimeout, _session.Time); try { extractionFailure = await _session.Writers.ExtractAsync(turn, response, deadline.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { - extractionFailure = ConversationSession.ExtractionTimedOutReason; + extractionFailure = TurnFailureReasons.ExtractionTimedOut; } if (extractionFailure is not null) { - _session.Events.RaiseDiagnostic( - ConversationEventKind.ExtractionFailed, - _session.Time.GetUtcNow(), - turn.Index, - payload: new Dictionary(StringComparer.Ordinal) - { - [ConversationEventPayloadKeys.Reason] = extractionFailure, - }); + SessionFaults.Raise(_session, FaultKind.ExtractionFailed, turn.Index, extractionFailure, cause: null); } return extractionFailure; @@ -252,12 +275,15 @@ private string AdvanceStage(ConversationTurn turn) string stageAfter = _session.Policy.Advance(_session.State.Snapshot()); _session.State.Stage = stageAfter; - _session.IsComplete = _session.Policy.IsTerminal; + // A host or a tool may have ended the conversation during this turn; a stage that is not terminal never + // opens it again. + _session.IsComplete = _session.Policy.IsTerminal || _session.Lifetime.Ending.Requested; + return stageAfter; } /// Reads the closed outcome value of one finished turn. - /// The section 8.7 reason, or . + /// The failure reason, or . /// The played duration, or . /// One of the three values the metric attribute takes. private static string Outcome(string? failure, TimeSpan? interruptedAfter) @@ -276,19 +302,30 @@ private static string Outcome(string? failure, TimeSpan? interruptedAfter) private void ReportUndeclaredToolCalls(ConversationTurn turn, AgentResponse response) { HashSet invoked = new(turn.Results.CallIds, StringComparer.Ordinal); + HashSet denied = new(turn.Results.DeniedCallIds.Concat(TurnApprovalAnswers.DeniedCallIds(turn.Spoken)), StringComparer.Ordinal); foreach (FunctionCallContent? call in response.Messages .SelectMany(message => message.Contents) .OfType() .Where(call => !invoked.Contains(call.CallId))) { - _session.Events.RaiseToolFailure(turn.Index, new ToolFailure + // An approval denial answered the call: the tool is declared, and the refusal is the outcome. + if (denied.Contains(call.CallId)) { - ToolName = call.Name, - ToolCallId = call.CallId, - Kind = ToolFailureKind.Undeclared, - Message = $"the model called '{call.Name}', and no such tool is declared.", - }); + if (_session.Hooks.Wants()) + { + _ = _session.Hooks.Raise(new ToolCalled( + _session.Hooks.Scope(turn.Index, turn.StageBefore), call.Name, call.CallId, TimeSpan.Zero, + ToolOutcome.Blocked, Fatal: false, FailureKind: null, Failure: null)); + } + + continue; + } + + _ = _session.Hooks.Raise(new ToolCalled( + _session.Hooks.Scope(turn.Index, turn.StageBefore), call.Name, call.CallId, TimeSpan.Zero, + ToolOutcome.Undeclared, Fatal: false, ToolFailureKind.Undeclared, + $"the model called '{call.Name}', and no such tool is declared.")); } } } diff --git a/src/AgentCore.Application/Runtime/ConversationTurnStream.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs similarity index 84% rename from src/AgentCore.Application/Runtime/ConversationTurnStream.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs index 52a09ae7..0ccb65b6 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnStream.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs @@ -1,14 +1,18 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Transcript; using AgentCore.Domain; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { internal sealed class ConversationTurnStream { @@ -58,14 +62,19 @@ internal async Task RunTurnCoreAsync( /// 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. + /// + /// What was said on a call ahead of that no turn answered, oldest first. The run + /// reads it, and the turn writes it, just before . + /// /// 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) + ChatMessage userInput, ConversationTurnOrigin? origin, IReadOnlyList before, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userInput); + ArgumentNullException.ThrowIfNull(before); - (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, cancellationToken).ConfigureAwait(false); + (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, before, cancellationToken).ConfigureAwait(false); return new TurnRun( turn.Index, @@ -84,7 +93,7 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync StrongBox? result, [EnumeratorCancellation] CancellationToken cancellationToken) { - (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, cancellationToken).ConfigureAwait(false); + (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, before: [], cancellationToken).ConfigureAwait(false); await foreach (ChatResponseUpdate update in RunAsync(turn, slot, result, new StrongBox()).ConfigureAwait(false)) { @@ -93,7 +102,7 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync } private async Task<(ConversationTurn Turn, TurnCutSlot Slot)> StartAsync( - ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken) + ChatMessage userInput, ConversationTurnOrigin? origin, IReadOnlyList before, CancellationToken cancellationToken) { long started = _session.Time.GetTimestamp(); @@ -116,7 +125,12 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync throw; } - ConversationTurn turn = _session.Runner.BeginTurn(userInput, _session.Ledger.Session() ?? session, origin); + ConversationTurn turn = _session.Runner.BeginTurn(userInput, _session.Ledger.Session() ?? session, origin, before); + + SessionHooks hooks = _session.Hooks; + _ = hooks.Raise(new TurnStarted( + hooks.Scope(turn.Index, turn.StageBefore), turn.Agent.Name ?? turn.Agent.Id, turn.Spoken.Text)); + TurnApprovalAnswers.TakeIn(hooks, turn); return (turn, _session.Cuts.StartRun(turn.Index, cancellationToken)); } @@ -157,7 +171,7 @@ private async IAsyncEnumerable RunAsync( IAsyncEnumerable runStream = turn.Agent .RunStreamingAsync( - [turn.Spoken], + [.. turn.Before, turn.Spoken], runSession, invocation.RunOptions(), cancellationToken: slot.Token); @@ -191,7 +205,7 @@ private async IAsyncEnumerable RunAsync( if (update.Contents.OfType().Any() || update.Contents.OfType().Any()) { - yield return update.AsChatResponseUpdate(); + yield return Delivered(update.AsChatResponseUpdate()); continue; } @@ -200,6 +214,12 @@ private async IAsyncEnumerable RunAsync( if (TurnMessages.CarriesContent(content) && IsOutput(update)) { + if (_session.Hooks.Wants()) + { + _ = _session.Hooks.Raise(new ReplyUpdated( + _session.Hooks.Scope(turn.Index, turn.StageBefore), content.Text, update.AuthorName)); + } + yield return content; } } @@ -238,7 +258,7 @@ private void EndRun(ConversationTurn turn, TurnCutSlot slot) /// private void AbandonRun(ConversationTurn turn, TurnCutSlot slot) { - TurnRefusals.Raise(_session, turn.Index, TurnRefusals.Dropped); + TurnRefusals.Raise(_session, turn.Index, TurnRefusal.Dropped); AgentCoreTelemetry.EndTurn( turn.Activity, @@ -250,6 +270,20 @@ private void AbandonRun(ConversationTurn turn, TurnCutSlot slot) EndRun(turn, slot); } + /// Hands the caller the file a carries, in place of the notice. + /// One notice update of the turn. + /// The same update, its file notices replaced by their files and named after who published them. + private static ChatResponseUpdate Delivered(ChatResponseUpdate update) + { + if (update.Contents.OfType().FirstOrDefault() is { } first) + { + update.AuthorName ??= first.Author; + update.Contents = [.. update.Contents.Select(static content => content is FileNotice notice ? notice.File : content)]; + } + + return update; + } + /// Reads whether the caller hears the node that produced one update. /// One update of the run. /// when the host should speak it. diff --git a/src/AgentCore.Application/Runtime/ConversationTurnWriters.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnWriters.cs similarity index 93% rename from src/AgentCore.Application/Runtime/ConversationTurnWriters.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnWriters.cs index e013db63..55dcb7eb 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnWriters.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnWriters.cs @@ -3,8 +3,9 @@ using AgentCore.Application.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { internal sealed class ConversationTurnWriters { @@ -32,8 +33,7 @@ internal ConversationTurnWriters(ConversationSession session) // before the caller spoke. A caller answering a question — "the ENT one", "yes", "the second // one" — cannot be read without the question, and the state document cannot carry it because // nothing was written yet. The rest of the conversation stays out: the document already carries every - // earlier answer. Measured 2026-09-02: with the question in view every such answer resolved, - // and a turn where only the agent had named a machine still filled nothing. + // earlier answer. With the question in view such answers resolve. List finished = [turn.Spoken, .. response.Messages]; if (_session.Transcript.LastOrDefault(message => message.Role == ChatRole.Assistant && message.Text.Length > 0) is { } asked) { diff --git a/src/AgentCore.Application/Runtime/ITurnCompleter.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ITurnCompleter.cs similarity index 96% rename from src/AgentCore.Application/Runtime/ITurnCompleter.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ITurnCompleter.cs index 328b1f65..e908a7e4 100644 --- a/src/AgentCore.Application/Runtime/ITurnCompleter.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ITurnCompleter.cs @@ -1,6 +1,7 @@ using AgentCore.Application.Transcript; +using AgentCore.Application.Runtime.Cut; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// /// The conversation's side of one turn's seal, which calls in a fixed diff --git a/src/AgentCore.Application/Runtime/ReplyOutcome.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ReplyOutcome.cs similarity index 84% rename from src/AgentCore.Application/Runtime/ReplyOutcome.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ReplyOutcome.cs index 824086c1..20500114 100644 --- a/src/AgentCore.Application/Runtime/ReplyOutcome.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ReplyOutcome.cs @@ -1,6 +1,8 @@ using AgentCore.Domain; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.ToolCalls; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// What the turn said, what the caller heard, and why they differ if they do. /// The text the record holds: what the caller heard. @@ -8,8 +10,8 @@ namespace AgentCore.Application.Runtime /// 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 message of the fault a tool let out (row six), or when no tool did. + /// The failure reason, or . + /// The message of the fault a tool let out, or when no tool did. /// The played duration of a cut reply, or . /// The tool calls still waiting on a human answer. /// diff --git a/src/AgentCore.Application/Runtime/ReplyText.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ReplyText.cs similarity index 96% rename from src/AgentCore.Application/Runtime/ReplyText.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/ReplyText.cs index 42c2539a..65ca1984 100644 --- a/src/AgentCore.Application/Runtime/ReplyText.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ReplyText.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// /// Picks the one message of a run that the caller actually hears. diff --git a/src/AgentCore.Application/Runtime/TurnDisposition.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs similarity index 82% rename from src/AgentCore.Application/Runtime/TurnDisposition.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs index ba7ab3d1..4ae17bab 100644 --- a/src/AgentCore.Application/Runtime/TurnDisposition.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs @@ -1,6 +1,8 @@ using Microsoft.Agents.AI; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Session; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// What moderation decided about the caller's words. internal enum ModerationOutcome @@ -21,10 +23,10 @@ internal enum FallbackCause /// It did not. The model answered. None, - /// Something below the layer threw. Section 8.7, row six. + /// Something below the layer threw. Faulted, - /// The run returned quietly with no text. Section 8.7, last row. + /// The run returned quietly with no text. EmptyReply, } @@ -52,5 +54,9 @@ internal sealed record TurnDisposition( string? FlaggedCategories, FallbackCause Fallback, Exception? FallbackFault, - string? ModerationReason); + string? ModerationReason) + { + /// Gets whether a BeforeTurn hook blocked the turn before moderation and the model. + public bool Blocked { get; init; } + } } diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnFailureReasons.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnFailureReasons.cs new file mode 100644 index 00000000..9b708a8a --- /dev/null +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnFailureReasons.cs @@ -0,0 +1,31 @@ +namespace AgentCore.Application.Runtime.Turn.Lifecycle +{ + /// The words a turn records when it does not answer, and how long its completion may take. + public static class TurnFailureReasons + { + /// The reason a turn that produced no text reports. + public const string EmptyReply = "the run returned an empty reply, so the turn spoke the fallback."; + + /// The reason a turn that lost its tool budget reports, before the message of the fault. + public const string ToolFailure = "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 RunFault = "the turn's run faulted, so it spoke the fallback."; + + /// The failure the turn records when the completion work passes its deadline. + internal const string ExtractionTimedOut = "the turn completion passed its deadline."; + + /// What the log records when the moderation endpoint runs out of time. + internal const string ModerationTimedOut = "the moderation endpoint passed its deadline."; + + /// What the log records when the moderation endpoint throws. + internal const string ModerationFaulted = "the moderation endpoint threw."; + + /// How long the work after the reply may take before it is abandoned. + internal static readonly TimeSpan CompletionTimeout = TimeSpan.FromSeconds(5); + } +} diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnOutcomes.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnOutcomes.cs new file mode 100644 index 00000000..9c20e790 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnOutcomes.cs @@ -0,0 +1,36 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Cut; + +namespace AgentCore.Application.Runtime.Turn.Lifecycle +{ + /// Reads the outcome of one sealed turn. + internal static class TurnOutcomes + { + /// Reads the outcome of one sealed turn. + /// What the turn said and why. + /// What the turn layers reported, or . + /// The cut the turn was sealed with, or . + /// Whether a fault reached the seal past every layer. + /// The closed outcome the notice carries. + internal static TurnOutcome Of(ReplyOutcome outcome, TurnDisposition? disposition, TurnCut? cut, bool escaped) + { + if (disposition?.Moderation is ModerationOutcome.Flagged || disposition?.Blocked == true) + { + return TurnOutcome.Blocked; + } + + // The seal's own stop cut (host cancel, or a reader that let go) carries neither field. + if (cut is { ShownText: null, Played: null }) + { + return TurnOutcome.Cancelled; + } + + if (outcome.Fault is null) + { + return outcome.Failure is null ? TurnOutcome.Answered : TurnOutcome.Empty; + } + + return escaped ? TurnOutcome.Faulted : TurnOutcome.Fallback; + } + } +} diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRefusals.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRefusals.cs new file mode 100644 index 00000000..5744491c --- /dev/null +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRefusals.cs @@ -0,0 +1,35 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Runtime.Turn.Lifecycle +{ + /// + /// Raises the refusal of a turn that keeps none of its words, so it leaves a notice even when the client + /// that could read the error is gone. + /// + internal static class TurnRefusals + { + /// Raises the notice of a refusal: busy, conflict, gone, dropped, disposed, terminal, in use. + /// 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. + /// + /// Why. + internal static void Raise(ConversationSession session, int? turnIndex, TurnRefusal reason) + { + SessionHooks hooks = session.Hooks; + bool afterEnd = session.Lifetime.Ending.Requested || reason == TurnRefusal.Terminal; + lock (session.TurnLock) + { + if (!afterEnd) + { + hooks.EnsureStarted(); + } + + _ = hooks.Raise(new TurnRefused(hooks.Scope(turnIndex, stage: null), reason, afterEnd)); + } + } + } +} diff --git a/src/AgentCore.Application/Runtime/TurnReplyResolution.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs similarity index 72% rename from src/AgentCore.Application/Runtime/TurnReplyResolution.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs index e705b593..447c6161 100644 --- a/src/AgentCore.Application/Runtime/TurnReplyResolution.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs @@ -1,11 +1,15 @@ using System.Text.Json; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; using AgentCore.Domain; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// Decides what one turn said, and why, from how its run ended. internal static class TurnReplyResolution @@ -45,10 +49,14 @@ internal static ReplyOutcome Resolve( string? failure = fault is null ? null - : (isToolFault ? ConversationSession.ToolFailureReason : ConversationSession.RunFaultReason) + : (isToolFault ? TurnFailureReasons.ToolFailure : TurnFailureReasons.RunFault) + " " + fault.Message; List approvals = ApprovalRequestsOf(response); + if (approvals.Count > 0 && session.Hooks.Wants()) + { + RaiseAsked(session, turn, response); + } if (interruption is { } cut) { @@ -59,21 +67,27 @@ internal static ReplyOutcome Resolve( || string.IsNullOrWhiteSpace(reply) || disposition?.Fallback is FallbackCause.EmptyReply)) { - failure ??= ConversationSession.EmptyReplyReason; + failure ??= TurnFailureReasons.EmptyReply; 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) + return new ReplyOutcome(reply, generatedText, failure, toolFault, interruptedAfter, approvals, fault, isToolFault); + } + + /// Raises one per request the turn leaves for a human, under the function call id. + private static void RaiseAsked(ConversationSession session, ConversationTurn turn, AgentResponse response) + { + HashSet reshown = new(turn.Results.ReshownCallIds, StringComparer.Ordinal); + foreach (ToolApprovalRequestContent request in response.Messages + .SelectMany(message => message.Contents) + .OfType()) { - Log.TurnRunFaulted(session.Logger, session.ConversationId, turn.Index, fault); - session.Events.RaiseDiagnostic(ConversationEventKind.RunFaulted, session.Time.GetUtcNow(), turn.Index); + if (request.ToolCall is FunctionCallContent call && !reshown.Contains(call.CallId)) + { + _ = session.Hooks.Raise(new ApprovalChanged( + session.Hooks.Scope(turn.Index, turn.StageBefore), call.Name, call.CallId, ApprovalState.Asked, ApprovalBy.Human, Reason: null)); + } } - - return new ReplyOutcome(reply, generatedText, failure, toolFault, interruptedAfter, approvals, fault, isToolFault); } /// Reads the approval requests one finished turn still waits on. diff --git a/src/AgentCore.Application/Runtime/TurnRun.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRun.cs similarity index 96% rename from src/AgentCore.Application/Runtime/TurnRun.cs rename to src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRun.cs index e1a38886..b4f11ca1 100644 --- a/src/AgentCore.Application/Runtime/TurnRun.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnRun.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn.Lifecycle { /// One turn that has started: its index, and its reply as it arrives. public sealed class TurnRun : IAsyncDisposable @@ -53,7 +53,6 @@ internal TurnRun(int turnIndex, IAsyncEnumerable updates, Ac 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; diff --git a/src/AgentCore.Application/Runtime/Turn/OuterCall.cs b/src/AgentCore.Application/Runtime/Turn/OuterCall.cs deleted file mode 100644 index cdce36ce..00000000 --- a/src/AgentCore.Application/Runtime/Turn/OuterCall.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace AgentCore.Application.Runtime.Turn -{ - /// One outermost tool call, open on every kind of attachment at once. - internal sealed class OuterCall : IDisposable - { - private readonly IDisposable[] _opened; - - private OuterCall(IDisposable[] opened) - { - _opened = opened; - } - - /// Opens the call on every kind, in list order. - /// Every kind the turn carries. - /// The id of the outermost tool call now running. - /// The scope. Disposing it closes every kind, last opened first. - public static OuterCall Open(IReadOnlyList attachments, string callId) - { - return new([.. attachments.Select(kind => kind.BeginOuterCall(callId))]); - } - - public void Dispose() - { - for (int index = _opened.Length - 1; index >= 0; index--) - { - _opened[index].Dispose(); - } - } - } -} diff --git a/src/AgentCore.Application/Runtime/Turn/TurnApprovalAnswers.cs b/src/AgentCore.Application/Runtime/Turn/TurnApprovalAnswers.cs new file mode 100644 index 00000000..e9772249 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Turn/TurnApprovalAnswers.cs @@ -0,0 +1,81 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime.Turn +{ + /// The approval answers a person sent as one turn's message, whichever door they came in by. + internal static class TurnApprovalAnswers + { + /// What a request is refused with when the caller sends new words instead of answering it. + internal const string MovedOnReason = "the user moved on without answering."; + + /// + /// Puts a refusal of every open request in front of the caller's new words, so the turn answers what the + /// conversation still asks and then runs the words. The refusals ride the turn's own user message, so the + /// record keeps one answer per request. + /// + /// What the caller sent. + /// The requests no answer covers yet. + /// A copy of led by one refusal per open request. + internal static ChatMessage MovedOn(ChatMessage words, IEnumerable open) + { + ChatMessage refused = words.Clone(); + refused.Contents = [.. open.Select(static request => (AIContent)request.CreateResponse(false, MovedOnReason)), .. words.Contents]; + return refused; + } + + /// + /// Reads the approval requests an edit withdraws with the turns that asked them, so no answer is written for + /// them. Only a parent the session holds is read: an edit under the summary keeps the requests it cannot see. + /// + /// The message store. + /// The session the conversation's words live on. + /// Where the turn hangs, or for a turn that is no edit. + /// The ids of the requests; empty when the turn withdraws none. + internal static IReadOnlySet Withdrawn(AgentCoreChatHistoryProvider history, AgentSession session, ConversationTurnOrigin? origin) + { + return origin is { NamesParent: true } edit && history.RowsAfter(session, edit.ParentMessageId) is { Held: true } after + ? new HashSet( + after.Rows.SelectMany(static row => row.Contents).OfType().Select(static request => request.RequestId), + StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + } + + /// Raises one per answer the turn takes in, and settles each call's open BeforeToolApproval decision. + /// The session's hooks. + /// The turn that just began. + internal static void TakeIn(SessionHooks hooks, ConversationTurn turn) + { + foreach ((ToolApprovalResponseContent answer, FunctionCallContent call) in Of(turn.Spoken)) + { + _ = hooks.ApprovalDecisions.TryRemove(call.CallId, out _); + if (hooks.Wants()) + { + _ = hooks.Raise(new ApprovalChanged( + hooks.Scope(turn.Index, turn.StageBefore), call.Name, call.CallId, + answer.Approved ? ApprovalState.Approved : ApprovalState.Denied, ApprovalBy.Human, Reason: answer.Reason)); + } + } + } + + /// The ids of the calls a person refused in the turn's message. + internal static IEnumerable DeniedCallIds(ChatMessage spoken) + { + return Of(spoken).Where(static pair => !pair.Answer.Approved).Select(static pair => pair.Call.CallId); + } + + private static IEnumerable<(ToolApprovalResponseContent Answer, FunctionCallContent Call)> Of(ChatMessage spoken) + { + foreach (ToolApprovalResponseContent answer in spoken.Contents.OfType()) + { + if (answer.ToolCall is FunctionCallContent call) + { + yield return (answer, call); + } + } + } + } +} diff --git a/src/AgentCore.Application/Runtime/Turn/TurnAttachments.cs b/src/AgentCore.Application/Runtime/Turn/TurnAttachments.cs index 49c55a83..d66d0f37 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnAttachments.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnAttachments.cs @@ -6,11 +6,6 @@ namespace AgentCore.Application.Runtime.Turn /// What a turn's tools have produced for the caller and not yet attached to a message, filed under /// the outermost tool call that produced it. /// - /// - /// The invoking client opens the outermost tool call around its invocation and takes what was filed - /// under it when it builds the tool-result message. Nested calls keep filing under the outer call; - /// outside any turn there is none, and a publish is discarded. - /// /// The content one publish files. internal abstract class TurnAttachments : ITurnAttachments where TContent : AIContent @@ -19,26 +14,6 @@ internal abstract class TurnAttachments : ITurnAttachments private readonly Dictionary> _byCallId = new(StringComparer.Ordinal); - /// Gets the id of the outermost tool call now running, or outside one. - protected string? OuterCallId { get; private set; } - - /// Opens one outermost tool call as the key publishes file under. - /// The id of the outermost tool call now running. - /// The scope. Disposing it puts back the key that was open before. - public IDisposable BeginOuterCall(string callId) - { - ArgumentNullException.ThrowIfNull(callId); - - string? previous; - lock (_gate) - { - previous = OuterCallId; - OuterCallId = callId; - } - - return new OuterCallScope(this, previous); - } - /// Takes what was filed under one outer tool call, in publish order. /// The call whose content to take. /// What that call filed, or empty. @@ -57,6 +32,18 @@ IReadOnlyList ITurnAttachments.TakeFor(string callId) return TakeFor(callId); } + /// Takes everything filed so far, under every call, in the order the calls first filed. + /// What was filed, or empty. + internal IReadOnlyList TakeAll() + { + lock (_gate) + { + List taken = [.. _byCallId.Values.SelectMany(static filed => filed)]; + _byCallId.Clear(); + return taken; + } + } + /// Files one content under a call. A later publish of the same thing wins, in the place the earlier one took, so publish order is kept. /// The outer call to file under. /// What to file. @@ -82,24 +69,5 @@ protected void Attach(string callId, TContent content, Predicate sameA } } } - - private sealed class OuterCallScope(TurnAttachments owner, string? previous) : IDisposable - { - private bool _closed; - - public void Dispose() - { - if (_closed) - { - return; - } - - _closed = true; - lock (owner._gate) - { - owner.OuterCallId = previous; - } - } - } } } diff --git a/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs b/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs index 98467102..11028843 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs @@ -4,7 +4,7 @@ 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). + /// other way, because the framework's hook runs after the last update. /// /// What the user's message was written under. /// What the last message was written under, or when the user's is the only one. diff --git a/src/AgentCore.Application/Runtime/Turn/TurnContextProvider.cs b/src/AgentCore.Application/Runtime/Turn/TurnContextProvider.cs index 7d4b5877..6f29047d 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnContextProvider.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnContextProvider.cs @@ -14,14 +14,19 @@ protected override ValueTask ProvideAIContextAsync( { ArgumentNullException.ThrowIfNull(context); - string? instructions = TurnRegistry.For(context.Session)?.Instructions; + TurnInvocation? turn = TurnRegistry.For(context.Session); + List messages = []; + if (turn?.Instructions is { Length: > 0 } instructions) + { + messages.Add(new ChatMessage(ChatRole.System, instructions)); + } - return new(new AIContext + if (turn?.AddedContext is { IsEmpty: false } added) { - Messages = string.IsNullOrEmpty(instructions) - ? null - : [new ChatMessage(ChatRole.System, instructions)], - }); + messages.AddRange(added.Select(static note => new ChatMessage(ChatRole.System, note))); + } + + return new(new AIContext { Messages = messages.Count == 0 ? null : messages }); } } } diff --git a/src/AgentCore.Application/Runtime/Turn/TurnFiles.cs b/src/AgentCore.Application/Runtime/Turn/TurnFiles.cs index ac44c513..d2e2f621 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnFiles.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnFiles.cs @@ -3,15 +3,53 @@ namespace AgentCore.Application.Runtime.Turn { /// What a turn has published to the caller's file store and not yet attached to a message. - internal sealed class TurnFiles : TurnAttachments + /// The turn-level collector a graph participant's collector reports what it delivered to, or . + internal sealed class TurnFiles(TurnFiles? turn = null) : TurnAttachments { - /// Files one published file under the outermost tool call, or drops it outside a turn. + private readonly List _delivered = []; + + /// Gets what reached the caller as cards, by this collector and every participant collector filed under it, in delivery order. + internal IReadOnlyList Delivered + { + get + { + lock (_delivered) + { + return [.. _delivered]; + } + } + } + + /// Records a file delivered to the caller, so the turn's stored words keep its card. + /// The file the store kept. + internal void Deliver(FileContent file) + { + ArgumentNullException.ThrowIfNull(file); + + lock (_delivered) + { + int index = _delivered.FindIndex(known => string.Equals(known.Name, file.Name, StringComparison.Ordinal)); + if (index >= 0) + { + _delivered[index] = file; + } + else + { + _delivered.Add(file); + } + } + + turn?.Deliver(file); + } + + /// Files one published file under the outermost tool call of the publishing flow, or drops it outside one. /// The file the store kept. - public void Publish(FileContent file) + /// The outermost tool call the publishing flow runs inside, or . + public void Publish(FileContent file, string? callId) { ArgumentNullException.ThrowIfNull(file); - if (OuterCallId is not { } callId) + if (callId is null) { return; } diff --git a/src/AgentCore.Application/Runtime/TurnInvocation.cs b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs similarity index 77% rename from src/AgentCore.Application/Runtime/TurnInvocation.cs rename to src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs index 7073a9a9..efbc8836 100644 --- a/src/AgentCore.Application/Runtime/TurnInvocation.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs @@ -1,12 +1,18 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Runtime.Harness; -using AgentCore.Application.Runtime.Turn; using AgentCore.Application.State; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn.Lifecycle; -namespace AgentCore.Application.Runtime +namespace AgentCore.Application.Runtime.Turn { // Everything one turn hands its tools, as one explicit value. The loop builds one per turn; // the invoking client files it beside every tool call's arguments, so a tool declares what @@ -36,12 +42,18 @@ internal sealed record TurnInvocation /// Gets what the turn may see of the knowledge base, or . public KnowledgeScope? Knowledge { get; init; } - /// Gets the conversation's ambiguity holder, or inside a nested tool call. + /// Gets the conversation's ambiguity holder, or on a nested run. public Clarifications? Clarifications { get; init; } /// Gets the instructions for this one invocation, or for none. public string? Instructions { get; init; } + /// Gets the context notes a BeforeTurn hook added for this turn's runs, or . Never stored. + public ConcurrentQueue? AddedContext { get; init; } + + /// Gets the line the caller hears when the entry refuses to answer, or outside a conversation turn. + public string? RefusalReply { get; init; } + /// Gets the zone the person on the conversation is in, or when the host named none. public TimeZoneInfo? TimeZone { get; init; } @@ -62,6 +74,18 @@ internal sealed record TurnInvocation /// public TurnNotices? Notices { get; init; } + /// Gets the session's hook raiser, or outside a conversation turn. + public SessionHooks? Hooks { get; init; } + + /// + /// Gets the turn's shared state, which gates expose as Items and tools as , + /// or outside a conversation turn. Nested runs share their parent's. + /// + public IDictionary? Items { get; init; } + + /// Gets the turn's model round counter, or outside a conversation turn. + public TurnRounds? Rounds { get; init; } + /// Gets what the turn publishes files into. Never on a loop-built turn. public TurnFiles? Files { get; init; } @@ -80,21 +104,18 @@ internal IReadOnlyList Attachments() /// Gets the id of the delegating tool are meant for. public string? ToolsFor { get; init; } - /// Gets what to do with a tool failure the run reports. - public Action? OnToolFailure { get; init; } - /// Gets the outermost tool call open on this flow, stamped by the invoking client, /// or before the first tool call. public string? OuterCallId { get; init; } - /// Gets whether this turn runs nested inside another tool call. A nested turn neither latches the holder nor records. + /// Gets whether this turn runs nested inside another tool call or as one participant of a graph. A nested turn neither latches the holder nor records. public bool Nested { get; init; } /// /// Gets the live state document of the conversation running this turn, or /// outside a turn. The graph-state wrapper snapshots it into the run just before the run starts; /// nothing writes the document mid-run (writers and the extractor commit after), so the snapshot - /// reads what the old ambient read at edge time. + /// reads what an ambient read would at edge time. /// public StateDocument? State { get; init; } @@ -107,6 +128,9 @@ internal IReadOnlyList Attachments() /// Gets the message the user sent this turn, or outside a conversation turn. public ChatMessage? User { get; init; } + /// Gets what was said on a call ahead of that no turn answered, oldest first. + public IReadOnlyList Before { 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; } @@ -122,6 +146,15 @@ internal IReadOnlyList Attachments() /// Gets where a cut of the running turn waits for the seal, or outside a conversation turn. public TurnCutSlot? CutSlot { get; init; } + /// + /// Gets the token a running tool reads in place of the turn's: a cut or a withdraw never cancels it, only the + /// conversation's end backstop does (). + /// + public CancellationToken ToolStop { get; init; } + + /// Gets the conversation's running tool calls, or outside a conversation. + public ConversationToolRuns? ToolRuns { 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/Turn/TurnMessages.cs b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs index f6764d9e..7538513a 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs @@ -111,6 +111,36 @@ internal static List FinishedMessages(IList messages) return kept; } + /// Keeps only the tool calls that have a result, and those results, without the words beside them. + /// The messages of the turns a withdrawal takes, oldest first. + /// One message per message that held a kept call or result, in the original order. + internal static List ToolPairs(IReadOnlyList messages) + { + HashSet answered = [.. messages.SelectMany(message => message.Contents).OfType().Select(result => result.CallId)]; + HashSet called = [.. messages.SelectMany(message => message.Contents).OfType().Select(call => call.CallId).Where(answered.Contains)]; + + List pairs = []; + foreach (ChatMessage message in messages) + { + List kept = + [ + .. message.Contents.Where(content => content switch + { + FunctionCallContent call => called.Contains(call.CallId), + FunctionResultContent result => called.Contains(result.CallId), + _ => false, + }), + ]; + + if (kept.Count > 0) + { + pairs.Add(new ChatMessage(message.Role, kept) { AuthorName = message.AuthorName }); + } + } + + return pairs; + } + /// /// The whole reply a turn's run produced, every step's text in order. /// diff --git a/src/AgentCore.Application/Runtime/Turn/TurnResults.cs b/src/AgentCore.Application/Runtime/Turn/TurnResults.cs index 51afafd1..dd7d2872 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnResults.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnResults.cs @@ -22,6 +22,10 @@ internal sealed class TurnResults private readonly HashSet _callIds = new(StringComparer.Ordinal); + private readonly HashSet _deniedCallIds = new(StringComparer.Ordinal); + + private readonly HashSet _reshownCallIds = new(StringComparer.Ordinal); + private readonly List>> _sweeps = []; /// The tools that answered something structured, in first-answer order. @@ -76,6 +80,54 @@ internal void NoteCall(string callId, Action> sweep) } } + /// Keeps the id of one call an approval denial answered, so the turn's end does not take it for an undeclared tool. + /// The id the model gave the call. + internal void NoteDenied(string callId) + { + ArgumentNullException.ThrowIfNull(callId); + + lock (_gate) + { + _ = _deniedCallIds.Add(callId); + } + } + + /// Every call id an approval denial answered this turn, in no particular order. + internal IReadOnlyList DeniedCallIds + { + get + { + lock (_gate) + { + return [.. _deniedCallIds]; + } + } + } + + /// Keeps the id of one call whose request an earlier turn already asked and this turn only shows again. + /// The id the model gave the call. + internal void NoteReshown(string callId) + { + ArgumentNullException.ThrowIfNull(callId); + + lock (_gate) + { + _ = _reshownCallIds.Add(callId); + } + } + + /// Every call id whose request this turn only showed again, in no particular order. + internal IReadOnlyList ReshownCallIds + { + get + { + lock (_gate) + { + return [.. _reshownCallIds]; + } + } + } + /// Sweeps every recorded client's drain state for this turn's call ids. internal void Sweep() { diff --git a/src/AgentCore.Application/Runtime/Turn/TurnSources.cs b/src/AgentCore.Application/Runtime/Turn/TurnSources.cs index 7137d977..fa7800c7 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnSources.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnSources.cs @@ -1,18 +1,19 @@ -using AgentCore.Application.Ports; using AgentCore.Application.Transcript; using AgentCore.Domain.Sources; namespace AgentCore.Application.Runtime.Turn { /// What a turn has cited and not yet attached to a message. - internal sealed class TurnSources : TurnAttachments, ISourcePort + internal sealed class TurnSources : TurnAttachments { - /// - public void Publish(SourceReference source) + /// Cites one source under the outermost tool call of the citing flow, or drops it outside one. + /// Where the answer came from. + /// The outermost tool call the citing flow runs inside, or . + public void Publish(SourceReference source, string? callId) { ArgumentNullException.ThrowIfNull(source); - if (OuterCallId is not { } callId) + if (callId is null) { return; } diff --git a/src/AgentCore.Application/Runtime/TurnRefusals.cs b/src/AgentCore.Application/Runtime/TurnRefusals.cs deleted file mode 100644 index 38603efd..00000000 --- a/src/AgentCore.Application/Runtime/TurnRefusals.cs +++ /dev/null @@ -1,47 +0,0 @@ -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/Secrets/KnownSecrets.cs b/src/AgentCore.Application/Secrets/KnownSecrets.cs index 6b152d8a..2c4af907 100644 --- a/src/AgentCore.Application/Secrets/KnownSecrets.cs +++ b/src/AgentCore.Application/Secrets/KnownSecrets.cs @@ -11,6 +11,12 @@ public static class KnownSecrets /// The standard OpenAI environment variable, read when the chain holds no name. public const string OpenAiApiKeyVariable = "OPENAI_API_KEY"; + /// The ${secret:name} name the OpenAI webhook signing secret resolves under. + public const string OpenAiWebhookSecretName = "openai-webhook-secret"; + + /// The environment variable the OpenAI webhook signing secret is read from. + public const string OpenAiWebhookSecretVariable = "OPENAI_WEBHOOK_SECRET"; + /// The ${secret:name} name the OpenCode Go key resolves under. public const string OpenCodeGoApiKeyName = "opencode-go-api-key"; @@ -56,6 +62,9 @@ public static class KnownSecrets /// The one OpenAI credential, which chat, embedding, and moderation all read. public static readonly SecretName OpenAi = new(OpenAiApiKeyName, OpenAiApiKeyVariable); + /// The secret OpenAI signs its webhooks with (Standard Webhooks, whsec_…). The openai-live adapter checks every call with it. + public static readonly SecretName OpenAiWebhook = new(OpenAiWebhookSecretName, OpenAiWebhookSecretVariable); + /// The OpenCode Go credential, which the chat adapter sends as its bearer token. public static readonly SecretName OpenCodeGo = new(OpenCodeGoApiKeyName, OpenCodeGoApiKeyVariable); diff --git a/src/AgentCore.Application/Sessions/Memory/HeldSession.cs b/src/AgentCore.Application/Sessions/Memory/HeldSession.cs index 28de9921..766e0c1a 100644 --- a/src/AgentCore.Application/Sessions/Memory/HeldSession.cs +++ b/src/AgentCore.Application/Sessions/Memory/HeldSession.cs @@ -1,4 +1,4 @@ -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Sessions.Memory { diff --git a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs index 5679b84c..13dc6442 100644 --- a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs +++ b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs @@ -1,9 +1,11 @@ using System.Collections.Concurrent; using AgentCore.Application.Conversation; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Sessions.Memory { @@ -91,6 +93,11 @@ public async ValueTask GetOrOpenAsync( if (!string.Equals(slot.Entry, entry, StringComparison.Ordinal)) { + if (slot.TryGetHeld(out HeldSession? holder)) + { + TurnRefusals.Raise(holder.Session, turnIndex: null, TurnRefusal.InUse); + } + throw new ConversationInUseException(id, slot.Entry, entry); } @@ -138,7 +145,7 @@ public async ValueTask CloseAsync(string entry, string conversationId, Cancellat if (held.TryEnd()) { - await TearDownAsync(conversationId, slot, held).ConfigureAwait(false); + await TearDownAsync(conversationId, slot, held, UnloadCause.Closed).ConfigureAwait(false); } else { @@ -176,11 +183,11 @@ public void Dispose() } /// - /// 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. + /// The one close routine every way out runs: flush, dispose (background children, then shells, then the + /// unload notice), 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) + private static async ValueTask CloseSessionAsync(ConversationSession session, UnloadCause cause) { try { @@ -190,7 +197,7 @@ private static async ValueTask CloseSessionAsync(ConversationSession session) } finally { - await session.DisposeAsync().ConfigureAwait(false); + await session.Lifetime.DisposeAsync(cause).ConfigureAwait(false); } } finally @@ -207,6 +214,7 @@ private ConversationSession Build( try { ConversationSession session = factory.Create(conversationId, state); + session.Lifetime.MarkOwned(); held = new(session, _idleTimeout, _time, expired => Expire(conversationId, slot, expired)); } catch (Exception fault) @@ -249,7 +257,7 @@ private async Task ExpireAsync(string conversationId, SessionSlot slot, HeldSess #pragma warning disable CA1031 try { - await TearDownAsync(conversationId, slot, held).ConfigureAwait(false); + await TearDownAsync(conversationId, slot, held, UnloadCause.Idle).ConfigureAwait(false); } catch (Exception exception) { @@ -262,11 +270,11 @@ private async Task ExpireAsync(string conversationId, SessionSlot slot, HeldSess /// 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) + private async Task TearDownAsync(string conversationId, SessionSlot slot, HeldSession held, UnloadCause cause) { try { - await CloseSessionAsync(held.Session).ConfigureAwait(false); + await CloseSessionAsync(held.Session, cause).ConfigureAwait(false); } finally { diff --git a/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs index 7f07eb63..5dde0b26 100644 --- a/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs +++ b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Sessions.Memory { diff --git a/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs b/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs index 21f0b9fd..f864fd98 100644 --- a/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs +++ b/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using AgentCore.Application.Hooks.Layers; using Microsoft.Extensions.AI; namespace AgentCore.Application.Skills @@ -31,6 +32,7 @@ internal PinnedSkillRedirect(AIFunction inner, IReadOnlySet pinned) if (ReadName(arguments) is { } name && _pinned.Contains(name)) { + ToolCallOutcomes.Mark(arguments, ToolCallOutcomes.RedirectedKey); return ValueTask.FromResult( $"The '{name}' skill is already in your instructions, inside . Use it from there."); } @@ -38,7 +40,7 @@ internal PinnedSkillRedirect(AIFunction inner, IReadOnlySet pinned) return base.InvokeCoreAsync(arguments, cancellationToken); } - private static string? ReadName(AIFunctionArguments arguments) + internal static string? ReadName(AIFunctionArguments arguments) { _ = arguments.TryGetValue(SkillNameArgument, out object? value); return value switch diff --git a/src/AgentCore.Application/Skills/PinnedSkillsProvider.cs b/src/AgentCore.Application/Skills/PinnedSkillsProvider.cs index 4870e0a5..ec4eb6e1 100644 --- a/src/AgentCore.Application/Skills/PinnedSkillsProvider.cs +++ b/src/AgentCore.Application/Skills/PinnedSkillsProvider.cs @@ -1,3 +1,5 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Turn; using Microsoft.Agents.AI; namespace AgentCore.Application.Skills @@ -41,10 +43,17 @@ protected override async ValueTask ProvideAIContextAsync( InvokingContext context, CancellationToken cancellationToken = default) { - return new AIContext + string instructions = _instructions ?? await ReadAsync(context, cancellationToken).ConfigureAwait(false); + + if (TurnRegistry.For(context.Session) is { Hooks: { } hooks } turn && hooks.Wants()) { - Instructions = _instructions ?? await ReadAsync(context, cancellationToken).ConfigureAwait(false), - }; + foreach (string name in _names) + { + _ = hooks.Raise(new SkillLoaded(hooks.Scope(turn.TurnIndex, turn.Stage), name, Pinned: true)); + } + } + + return new AIContext { Instructions = instructions }; } /// diff --git a/src/AgentCore.Application/State/StateExtractor.cs b/src/AgentCore.Application/State/StateExtractor.cs index 93862b5d..67c87a06 100644 --- a/src/AgentCore.Application/State/StateExtractor.cs +++ b/src/AgentCore.Application/State/StateExtractor.cs @@ -1,30 +1,14 @@ using System.Text.Json; using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Clarification; namespace AgentCore.Application.State { /// - /// Writer 1 of section 8.3, and the bridge from prose to typed state. + /// The bridge from prose to typed state. /// - /// - /// - /// AgentCore builds one JSON Schema from every writer: extractor slot, all nullable, and asks - /// a model to fill what the turn shows and leave the rest null. The extractor never speaks, holds no - /// tools, and decides nothing. The guards decide. - /// - /// - /// Every slot is nullable on the wire, and that is not optional. A required field that the model - /// omits becomes the default silently, so "unfilled" and "filled false" would be the same state. A - /// null answer means the model did not answer, and it leaves the slot at its previous value. - /// - /// - /// The extractor has no retry. One conversation runs, and a reply that does not deserialize leaves the slots - /// unchanged. The stage machine stays where it is and the agent tries again next turn. - /// - /// public sealed class StateExtractor { /// The name the emitted JSON Schema answers to. @@ -104,7 +88,7 @@ public static JsonElement BuildSchema(AgentCoreConfiguration configuration) JsonObject property = new() { - // The null member is the point of this schema. See section 8.3. + // The null member is the point of this schema. ["type"] = new JsonArray(JsonValue.Create(TypeName(slot.Type)), JsonValue.Create("null")), }; @@ -189,7 +173,7 @@ internal async Task ExtractAsync( } catch (Exception exception) when (exception is not OperationCanceledException) { - // State extraction must never drop a conversation. Section 8.7. + // State extraction must never drop a conversation. return StateExtractionResult.Failed($"the extractor call failed: {exception.Message}"); } @@ -211,8 +195,8 @@ public StateExtractionResult Write(StateDocument state, string? replyText) /// The state of one conversation. /// The raw JSON the model returned. /// - /// The conversation's ambiguity holder (K36). A write that lands clears the slot's lastNamed - /// record (K30); a refused write leaves it alone. + /// The conversation's ambiguity holder. A write that lands clears the slot's lastNamed + /// record; a refused write leaves it alone. /// /// What the reply did to the state. internal StateExtractionResult Write( @@ -278,7 +262,7 @@ private static bool WriteSlot(StateDocument state, string slot, JsonNode value, return true; } - /// K30: a successful write clears the slot's lastNamed record. + /// A successful write clears the slot's lastNamed record. private static void ClearAmbiguity(Clarifications clarifications, string slot) { clarifications.Update(slot, s => s.LastNamed = Clarifications.LastNamed.None); diff --git a/src/AgentCore.Application/Tools/Binding/ToolParameterBindings.cs b/src/AgentCore.Application/Tools/Binding/ToolParameterBindings.cs index 7779e878..6a3352ba 100644 --- a/src/AgentCore.Application/Tools/Binding/ToolParameterBindings.cs +++ b/src/AgentCore.Application/Tools/Binding/ToolParameterBindings.cs @@ -1,6 +1,7 @@ using System.Reflection; -using AgentCore.Application.Runtime; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tools.Binding { diff --git a/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs b/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs index e6690682..bb867557 100644 --- a/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs +++ b/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs @@ -3,13 +3,14 @@ using AgentCore.Application.Blobs; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tools.Builtin { @@ -17,10 +18,6 @@ namespace AgentCore.Application.Tools.Builtin /// Copies one file out of the running conversation's workspace into the blob store, owned by the conversation, and /// files a on the turn so the card reaches the person. /// - /// - /// A background child runs with no turn, so it has no drain to file the card on: its blob is still - /// stored under the parent conversation, and the link goes back to the child in the result alone. - /// internal sealed class FilePublishTool { private readonly IBlobStore _blobs; @@ -133,7 +130,13 @@ private async ValueTask PublishAsync( MediaType = blob.MediaType, Length = blob.Length, Kept = true, - }); + }, turn.OuterCallId); + + if (turn?.Hooks is { } hooks) + { + _ = hooks.Raise(new FilePublished( + hooks.Scope(turn.TurnIndex, turn.Stage), blob.Name, blob.Length, blob.MediaType, $"{blob.OwnerId}/{blob.Name}")); + } return new JsonObject { diff --git a/src/AgentCore.Application/Tools/CachedTool.cs b/src/AgentCore.Application/Tools/CachedTool.cs index a7a6771f..d4446a4a 100644 --- a/src/AgentCore.Application/Tools/CachedTool.cs +++ b/src/AgentCore.Application/Tools/CachedTool.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Layers; using Microsoft.Extensions.AI; using Microsoft.Extensions.Caching.Hybrid; @@ -48,6 +49,7 @@ public CachedTool(AIFunction inner, HybridCache cache, TimeSpan lifetime) if (kept is not null) { + ToolCallOutcomes.Mark(arguments, ToolCallOutcomes.CachedKey); return JsonNode.Parse(kept); } diff --git a/src/AgentCore.Application/Tools/TimeLimitedTool.cs b/src/AgentCore.Application/Tools/TimeLimitedTool.cs index 3f3d4e18..994d79e7 100644 --- a/src/AgentCore.Application/Tools/TimeLimitedTool.cs +++ b/src/AgentCore.Application/Tools/TimeLimitedTool.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Layers; using Microsoft.Extensions.AI; namespace AgentCore.Application.Tools @@ -38,6 +39,7 @@ public TimeLimitedTool(AIFunction inner, TimeSpan limit) { // The turn itself being cancelled is not this: that token is the caller's, and it is left // to propagate. Only this wrapper's own deadline becomes a result. + ToolCallOutcomes.Mark(arguments, ToolCallOutcomes.TimedOutKey); return Failed(); } } diff --git a/src/AgentCore.Application/Tools/ToolCallScope.cs b/src/AgentCore.Application/Tools/ToolCallScope.cs index faad83c5..96e8a837 100644 --- a/src/AgentCore.Application/Tools/ToolCallScope.cs +++ b/src/AgentCore.Application/Tools/ToolCallScope.cs @@ -5,5 +5,12 @@ namespace AgentCore.Application.Tools /// The zero-based index of the turn now running. /// The stage the conversation's state machine holds. Empty when the document declares no policy. /// The conversation's folder on disk, or when the host bound no workspace root. - public sealed record ToolCallScope(string ConversationId, int TurnIndex, string Stage, string? Workspace = null); + public sealed record ToolCallScope(string ConversationId, int TurnIndex, string Stage, string? Workspace = null) + { + /// + /// Gets the turn's shared state: the same dictionary a hook's gate exposes as Items. It lives for one + /// turn. Durable data belongs in ConversationRecord.Custom. + /// + public IDictionary? Items { get; init; } + } } diff --git a/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs b/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs index d4e1bf08..64718d8c 100644 --- a/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs +++ b/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs @@ -15,7 +15,7 @@ internal sealed class AgentCoreChatHistoryProvider : ChatHistoryProvider { /// /// 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). + /// nothing and commits nothing, whatever else its bag carries. /// internal const string ConversationKey = "agentcore.history"; @@ -26,7 +26,7 @@ internal sealed class AgentCoreChatHistoryProvider : ChatHistoryProvider private readonly ConversationWrites _writes; /// Creates the provider over a backing store. - /// Store 1, or for one kept in this process. + /// The message store, 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) @@ -47,12 +47,12 @@ public AgentCoreChatHistoryProvider(IConversationStore? store = null, ILogger? l /// The session this conversation runs on. /// The conversation being opened. /// - /// What store 1 already holds for this conversation, which is empty for a conversation that is new. A second + /// What the message store 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 counted, if anywhere. + /// Where a dropped message store 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 + /// How far the conversation had got, from the conversation store'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 /// would otherwise answer for it. /// @@ -83,7 +83,7 @@ public int BeginConversation( /// 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 position. Once completes, store 0's counter equals + /// The position. Once completes, the conversation store's counter equals /// unless a row was written by someone else, or one of this /// session's own writes was dropped. /// @@ -97,11 +97,11 @@ public TranscriptPosition Position(AgentSession session) } /// - /// 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. + /// Replaces the conversation's live history with what the message store holds, unless the session's words moved since + /// the read began. Words that moved hold a write the message store may not have yet, and replacing them would lose it. /// /// The session this conversation runs on. - /// What store 1 held, and the revision the read began at. + /// What the message store held, and the revision the read began at. /// when the words moved and nothing was replaced. public bool TryResync(AgentSession session, TranscriptCatchUp catchUp) { @@ -143,7 +143,7 @@ public bool TryResync(AgentSession session, TranscriptCatchUp catchUp) /// /// Puts one summary in place of every row at or below , unless the - /// words moved since was read, and queues the summary's row for store 1 ahead + /// words moved since was read, and queues the summary's row for the message store ahead /// of the turn's own words. /// /// The session this conversation runs on. @@ -185,6 +185,7 @@ public void BeginTurn(AgentSession session, int turnIndex) { transcript.BeginTurn(turnIndex); gate.Staged.Clear(); + gate.Replay.Clear(); return true; }); } @@ -227,6 +228,24 @@ public IReadOnlyList Staged(AgentSession session) return IsBound(session) ? _writes.Commit(session, commit) : null; } + /// Appends rows between turns, under the turn the conversation takes next. An unbound session writes nothing. + public void AppendBetweenTurns(AgentSession session, IReadOnlyList messages, int nextTurnIndex) + { + if (IsBound(session)) + { + _writes.AppendBetweenTurns(session, messages, nextTurnIndex); + } + } + + /// Queues the write of a state no turn's words carry, behind every write already queued. An unbound session writes nothing. + public void SaveState(AgentSession session, ConversationSessionState state) + { + if (IsBound(session)) + { + _writes.SaveState(session, state); + } + } + /// Withdraws everything the conversation said after one message, because a caller replaced it. /// The session this conversation runs on. /// @@ -251,6 +270,20 @@ public IReadOnlyList Staged(AgentSession session) return _writes.TruncateFromAsync(session, parentMessageId, cancellationToken); } + /// + /// Reads the live rows an edit hanging off one message would withdraw: every row after it, or every live row when + /// the session does not hold it, as an edit under the summary reads them. + /// + /// The session this conversation runs on. + /// The message the edit hangs off, or for the whole conversation. + /// The rows, oldest first, and whether the session holds the parent (always, for the whole conversation). + public (IReadOnlyList Rows, bool Held) RowsAfter(AgentSession session, string? parentMessageId) + { + ArgumentNullException.ThrowIfNull(session); + + return UnderLock(session, transcript => (transcript.After(parentMessageId), parentMessageId is null || transcript.OrdinalOf(parentMessageId) is not null)); + } + /// /// 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. @@ -297,14 +330,17 @@ protected override ValueTask> ProvideChatHistoryAsync( { ArgumentNullException.ThrowIfNull(context); - return new(context.Session is { } session && IsBound(session) ? Read(session) : []); + return new(context.Session is { } session && IsBound(session) + ? UnderLock(session, static (transcript, gate) => gate.Replay.Count == 0 ? transcript.Read() : [.. transcript.Read(), .. gate.Replay]) + : []); } /// /// 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). + /// . Staging appends. The first run of a turn reads the conversation only; a later + /// run of the same turn (a loop: round, an AfterRun continue, an approval re-entry) also reads what the + /// earlier runs were told and answered, which is never committed beyond the staged response. /// protected override ValueTask StoreChatHistoryAsync( InvokedContext context, CancellationToken cancellationToken = default) @@ -318,6 +354,8 @@ protected override ValueTask StoreChatHistoryAsync( (_, gate) => { gate.Staged.AddRange(response); + gate.Replay.AddRange(ExternalOnly(context.RequestMessages).Select(Detached)); + gate.Replay.AddRange(response.Select(Detached)); return true; }); } @@ -331,12 +369,21 @@ private static bool IsBound(AgentSession session) } // 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). + // system lines (clock, reminders, skills) arrive in the request too. private static IEnumerable ExternalOnly(IEnumerable messages) { return messages.Where(message => message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External); } + // MAF tags each message it reads from this provider in the message's property bag. A replayed message shares + // nothing with the staged one, so the tag never reaches a stored row. + private static ChatMessage Detached(ChatMessage message) + { + ChatMessage copy = message.Clone(); + copy.AdditionalProperties = message.AdditionalProperties?.Clone(); + return copy; + } + private TResult UnderLock(AgentSession session, Func work) { return _gates.Under(session, work); diff --git a/src/AgentCore.Application/Transcript/ConversationGate.cs b/src/AgentCore.Application/Transcript/ConversationGate.cs index edc616e9..633995a1 100644 --- a/src/AgentCore.Application/Transcript/ConversationGate.cs +++ b/src/AgentCore.Application/Transcript/ConversationGate.cs @@ -10,8 +10,7 @@ namespace AgentCore.Application.Transcript /// What one conversation holds outside its state bag: its lock, its transcript, and its queue of /// 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. + /// The message store, where every queued write lands. /// Where a refused write is logged. internal sealed class ConversationGate(IConversationStore store, ILogger logger) { @@ -38,6 +37,13 @@ internal sealed class ConversationGate(IConversationStore store, ILogger logger) /// public List Staged { get; } = []; + /// + /// Gets what the running turn's finished runs were told and answered, in order. A later run of the same turn + /// reads it after the conversation, since the framework hands the caller's input to the first run only. It is + /// never committed: the turn's user row and are. The next turn's start clears it. + /// + public List Replay { get; } = []; + /// Takes everything staged for the running turn, and leaves nothing staged. public List TakeStaged() { @@ -60,6 +66,13 @@ public Task QueueAppend(IReadOnlyList rows, Conversat [.. drafts.Select(draft => draft.MessageId)]); } + /// Queues the write of a state no rows carry, behind every write already queued. + public void QueueState(ConversationSessionState state) + { + string conversationId = Transcript.ConversationId; + _ = Enqueue(() => _store.SaveStateAsync(conversationId, state, CancellationToken.None)); + } + /// Queues the rewrite of rows the transcript just changed in place. public void QueueRewrite(IReadOnlyList rows) { @@ -117,11 +130,14 @@ private async Task WriteAfterAsync( 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. +#pragma warning disable CA1031 // A message store write failure never ends a conversation, and never breaks the chain behind it. catch (Exception exception) #pragma warning restore CA1031 { - Log.TranscriptWriteFailed(_logger, conversationId, turnIndex, exception); + if (Losses is null) + { + Log.TranscriptWriteFailed(_logger, conversationId, 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 index a9a499dc..d9244b54 100644 --- a/src/AgentCore.Application/Transcript/ConversationGates.cs +++ b/src/AgentCore.Application/Transcript/ConversationGates.cs @@ -10,7 +10,7 @@ namespace AgentCore.Application.Transcript /// 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. + /// The message store, which every gate writes through to. /// Where a refused write is logged. internal sealed class ConversationGates(IConversationStore store, ILogger logger) { diff --git a/src/AgentCore.Application/Transcript/ConversationMessage.cs b/src/AgentCore.Application/Transcript/ConversationMessage.cs index 005ca43d..a81f832c 100644 --- a/src/AgentCore.Application/Transcript/ConversationMessage.cs +++ b/src/AgentCore.Application/Transcript/ConversationMessage.cs @@ -2,12 +2,12 @@ namespace AgentCore.Application.Transcript { - /// One stored message of one conversation. It is one row of store 1. + /// One stored message of one conversation. It is one row of the message store. /// The conversation the message belongs to. /// /// The message's position within the conversation. It is monotonic and never reused. It is not dense: an /// edit deletes the rows it replaces and the ordinals they held are not issued again, because - /// store 3 still holds audit rows against the turns those ordinals belonged to. + /// the audit store still holds audit rows against the turns those ordinals belonged to. /// /// The turn the message belongs to. It is the join to the audit chain. /// The message itself. @@ -25,7 +25,7 @@ public sealed record ConversationMessage( /// /// Gets the last ordinal this row stands in for, when it is a summary the session wrote in place /// of every row at or below it; for a row somebody said. Every consumer - /// read of store 1 leaves summary rows out; only the session's own read returns one. + /// read of the message store leaves summary rows out; only the session's own read returns one. /// public int? CoversUpTo { get; init; } } diff --git a/src/AgentCore.Application/Transcript/ConversationTranscript.cs b/src/AgentCore.Application/Transcript/ConversationTranscript.cs index 5d0f2743..c10770d3 100644 --- a/src/AgentCore.Application/Transcript/ConversationTranscript.cs +++ b/src/AgentCore.Application/Transcript/ConversationTranscript.cs @@ -44,21 +44,21 @@ public int Resume(IReadOnlyList rows, TranscriptMarks marks } /// - /// Replaces the live history with what store 1 holds, without disturbing + /// Replaces the live history with what the message store holds, without disturbing /// or . /// /// - /// The rows store 1 read for the session: every row, or the newest summary and the rows above what + /// The rows the message store read for the session: every row, or the newest summary and the rows above what /// it covers. A row a summary stands in for is the caller's mistake, not this method's to catch: /// it lands in the live history beside the summary that already speaks for it. Order does not matter. /// - /// The next free ordinal of the conversation, from store 0's own counter. + /// The next free ordinal of the conversation, from the conversation store's own counter. public void Resync(IReadOnlyList rows, int nextOrdinal) { ArgumentNullException.ThrowIfNull(rows); // Stripped for the reason Append gives: the live history is serialised into the session - // state bag every turn, and what store 1 keeps is bigger than that bag should ever carry. + // state bag every turn, and what the message store keeps is bigger than that bag should ever carry. Messages = [.. rows .OrderBy(row => row.Ordinal) .Select(row => new StoredMessage @@ -143,6 +143,17 @@ public bool CanTruncateFrom(string? parentMessageId) return new WithdrawnTurns(first, last); } + /// + /// Reads the live rows an edit hanging off one message would withdraw: every row after it, or every live row + /// when this transcript does not hold it. + /// + /// The message the edit hangs off, or for the whole conversation. + public IReadOnlyList After(string? parentMessageId) + { + int from = parentMessageId is not null && OrdinalOf(parentMessageId) is { } parent ? parent + 1 : 0; + return [.. Messages.Where(stored => Withdrawn(stored, from)).Select(static stored => stored.Message)]; + } + /// The ids of the rows withdraws from on. public IReadOnlyList IdsFrom(int fromOrdinal) { @@ -220,7 +231,7 @@ private IEnumerable View(int count) /// The message that now speaks for those rows. /// The last ordinal it speaks for. Never below what the standing covers. /// The the compaction read. - /// The row the summary became, for store 1; or when the compaction was stale and dropped. + /// The row the summary became, for the message store; or when the compaction was stale and dropped. public ConversationMessage? Compact(ChatMessage summary, int coversUpTo, int revision) { ArgumentNullException.ThrowIfNull(summary); @@ -255,13 +266,16 @@ private IEnumerable View(int count) /// Adds new messages to the conversation, and returns the rows they became. /// The messages the turn produced, oldest first. - /// - /// What the caller calls the first of them, or to name it here. Only the - /// first: it is the one the caller sent and so the only one the caller had a name for. + /// + /// What the caller calls the message at , or to name it here. + /// Only that one: it is the one the caller sent and so the only one the caller had a name for. /// + /// Where the caller's message sits among . + /// The turn the rows belong to, or for the turn running now. public IReadOnlyList Append( - IReadOnlyList messages, string? firstMessageId = null) + IReadOnlyList messages, string? userMessageId = null, int userAt = 0, int? turnIndex = null) { + int rowTurn = turnIndex ?? TurnIndex; ArgumentNullException.ThrowIfNull(messages); List rows = new(messages.Count); @@ -271,14 +285,14 @@ public IReadOnlyList Append( int ordinal = NextOrdinal++; - string messageId = (index == 0 ? firstMessageId : null) ?? NewMessageId(); + string messageId = (index == userAt ? userMessageId : null) ?? NewMessageId(); - rows.Add(new ConversationMessage(ConversationId, ordinal, TurnIndex, message, messageId)); + rows.Add(new ConversationMessage(ConversationId, ordinal, rowTurn, message, messageId)); Messages.Add(new StoredMessage { Ordinal = ordinal, - TurnIndex = TurnIndex, + TurnIndex = rowTurn, MessageId = messageId, Message = message.WithoutHostContent(), }); @@ -312,7 +326,7 @@ public ReplyRewrite RewriteReply(string heard, int? turnIndex = null) } List replies = Messages.FindAll(stored => - stored.TurnIndex == spoken.TurnIndex && stored.Message.Role == ChatRole.Assistant && stored.CoversUpTo is null); + stored.TurnIndex == spoken.TurnIndex && FrontVoice.IsAgentReply(stored.Message) && stored.CoversUpTo is null); List laid = ShownWords.Lay([.. replies.Select(stored => stored.Message)], heard); List rows = []; diff --git a/src/AgentCore.Application/Transcript/ConversationWrites.cs b/src/AgentCore.Application/Transcript/ConversationWrites.cs index 774b8cd6..a6f20d8a 100644 --- a/src/AgentCore.Application/Transcript/ConversationWrites.cs +++ b/src/AgentCore.Application/Transcript/ConversationWrites.cs @@ -11,9 +11,8 @@ 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. + /// The message store, which an edit under the summary cuts directly. /// Where cuts and withdrawals are logged. internal sealed class ConversationWrites(ConversationGates gates, IConversationStore store, ILogger logger) { @@ -34,17 +33,38 @@ public TurnWrite Commit(AgentSession session, TurnCommit commit) session, (transcript, gate) => { + int user = commit.Before.Count; IReadOnlyList rows = transcript.Append( - TurnWords.Compose(commit, gate.TakeStaged()), commit.UserMessageId); + TurnWords.Compose(commit, gate.TakeStaged()), commit.UserMessageId, user); Task refused = gate.QueueAppend(rows, commit.State); return new TurnWrite( - rows[0].MessageId, - rows.Count > 1 ? rows[^1].MessageId : null, + rows[user].MessageId, + rows.Count > user + 1 ? rows[^1].MessageId : null, TurnWords.Spoken(rows.Select(row => row.Content)), refused); }); } + /// Appends rows between turns, under the turn the conversation takes next. + public void AppendBetweenTurns(AgentSession session, IReadOnlyList messages, int nextTurnIndex) + { + _ = _gates.Under( + session, + (transcript, gate) => gate.QueueAppend(transcript.Append(messages, turnIndex: nextTurnIndex), state: null)); + } + + /// Queues the write of a state no turn's words carry. + public void SaveState(AgentSession session, ConversationSessionState state) + { + _ = _gates.Under( + session, + (_, gate) => + { + gate.QueueState(state); + return true; + }); + } + /// 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) @@ -151,7 +171,7 @@ public bool Rewrite(AgentSession session, int turnIndex, string text) ConversationRecord refreshed = await _store.GetAsync(conversationId, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException( - $"Store 0 holds no conversation '{conversationId}' for its own session to edit."); + $"The conversation store holds no conversation '{conversationId}' for its own session to edit."); IReadOnlyList rows = await _store.ReadForSessionAsync(conversationId, cancellationToken).ConfigureAwait(false); _ = _gates.Under( diff --git a/src/AgentCore.Application/Transcript/FrontVoice.cs b/src/AgentCore.Application/Transcript/FrontVoice.cs new file mode 100644 index 00000000..c8b2ab52 --- /dev/null +++ b/src/AgentCore.Application/Transcript/FrontVoice.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Transcript +{ + /// + /// The lines a phone vendor's own voice spoke to the caller with no turn of ours behind them, such as GPT-Live + /// answering a small question itself. They are stored as assistant messages under , so + /// the agent reads them as said, and nothing counts them as the agent's reply. + /// + internal static class FrontVoice + { + /// The author of every front-voice line. The Postgres verify query names it too. + internal const string AuthorName = "front_voice"; + + /// Builds one front-voice line. + internal static ChatMessage Line(string text) => new(ChatRole.Assistant, text) { AuthorName = AuthorName }; + + /// Whether a message is the agent's own reply: an assistant message no front voice spoke. + internal static bool IsAgentReply(ChatMessage message) => + message.Role == ChatRole.Assistant && !string.Equals(message.AuthorName, AuthorName, StringComparison.Ordinal); + } +} diff --git a/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs b/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs index b8e48f42..c46cbdfa 100644 --- a/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs +++ b/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs @@ -8,7 +8,7 @@ namespace AgentCore.Application.Transcript /// internal interface ITranscriptLossCounter { - /// Reports one dropped store 1 write, and counts it lost. Never throws. + /// Reports one dropped message store 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 diff --git a/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs b/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs index 90380ff3..5fb64b61 100644 --- a/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs +++ b/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs @@ -2,14 +2,14 @@ namespace AgentCore.Application.Transcript { - /// What store 1 held beyond a session's words, read without changing them. + /// What the message store 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. + /// The rows the message store read for the session. + /// The next free ordinal of the conversation, from the conversation store's own counter. + /// The state the conversation store 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. + /// Gets the index the conversation's next turn takes, from the state the conversation store 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. diff --git a/src/AgentCore.Application/Transcript/TranscriptLoss.cs b/src/AgentCore.Application/Transcript/TranscriptLoss.cs index 9e468f05..e93a55b0 100644 --- a/src/AgentCore.Application/Transcript/TranscriptLoss.cs +++ b/src/AgentCore.Application/Transcript/TranscriptLoss.cs @@ -1,6 +1,6 @@ namespace AgentCore.Application.Transcript { - /// One store 1 write of a session that the store did not take, or did not say it took. + /// One message store 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. diff --git a/src/AgentCore.Application/Transcript/TranscriptLosses.cs b/src/AgentCore.Application/Transcript/TranscriptLosses.cs index 72f391af..2a37736c 100644 --- a/src/AgentCore.Application/Transcript/TranscriptLosses.cs +++ b/src/AgentCore.Application/Transcript/TranscriptLosses.cs @@ -1,7 +1,7 @@ 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. + /// The message store 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 diff --git a/src/AgentCore.Application/Transcript/TranscriptPosition.cs b/src/AgentCore.Application/Transcript/TranscriptPosition.cs index 1cb5022b..34a7289d 100644 --- a/src/AgentCore.Application/Transcript/TranscriptPosition.cs +++ b/src/AgentCore.Application/Transcript/TranscriptPosition.cs @@ -1,7 +1,7 @@ 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. + /// Completes when every message store 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 index 52185736..961fa296 100644 --- a/src/AgentCore.Application/Transcript/TurnCommit.cs +++ b/src/AgentCore.Application/Transcript/TurnCommit.cs @@ -1,13 +1,13 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn.Lifecycle; 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. + /// The message the user sent. It is written first, after . internal sealed record TurnCommit(ChatMessage User) { /// Gets what the run yielded as the caller saw it, or when it yielded nothing. @@ -31,6 +31,9 @@ internal sealed record TurnCommit(ChatMessage User) /// public bool CallerFacing { get; init; } + /// Gets the files a caller-facing turn handed the caller as cards. The reply keeps them, as a single agent's turn keeps its cards. + public IReadOnlyList Files { 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 . @@ -40,6 +43,26 @@ internal sealed record TurnCommit(ChatMessage User) /// Gets the state that rides the same batch as the words, or for none. public ConversationSessionState? State { get; init; } + /// + /// Gets the call ids whose approval requests the record leaves out: a hook denied them, so no person was asked + /// and the record keeps the call and its refusal; or an earlier turn already stored them and this turn only + /// showed them again. A stored request with no stored answer, or one stored twice, would break every later run + /// of the conversation. + /// + public IReadOnlyCollection Unasked { get; init; } = []; + + /// + /// Gets the tool calls and results of the turns this turn's edit withdrew. They are written right after + /// , whatever else the turn keeps, because those tools already ran. + /// + public IReadOnlyList Carried { get; init; } = []; + + /// + /// Gets what was said on a call ahead of that no turn answered, oldest first: the caller's + /// words and the lines that answered them. They are written just before it. + /// + public IReadOnlyList Before { 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 index 290b3fc8..16eb4dc4 100644 --- a/src/AgentCore.Application/Transcript/TurnWords.cs +++ b/src/AgentCore.Application/Transcript/TurnWords.cs @@ -1,13 +1,13 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Transcript { - /// Decides the messages one sealed turn writes, by the Cut rule of design section 3. + /// Decides the messages one sealed turn writes. internal static class TurnWords { - /// Builds the messages of one turn, the user's first. + /// Builds the messages of one turn: what was said ahead of the user's, then the user's. /// The turn being sealed. /// Every message the framework staged for the turn, oldest first. /// @@ -19,17 +19,24 @@ internal static class TurnWords /// public static List Compose(TurnCommit commit, List staged) { - List words = [commit.User]; + List words = [.. commit.Before, commit.User, .. commit.Carried]; + IList asked = WithoutUnasked(commit, staged); - // 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; + // A cancelled or faulted run stages nothing, so what the caller saw is the fuller record. + IList source = commit.Seen?.Messages is { } yielded ? WithoutUnasked(commit, yielded) : asked; if (commit.CallerFacing) { string heard = commit.Reply ?? commit.Cut?.ShownText ?? ReplyText.From(source); - if (heard.Length > 0) + if (heard.Length > 0 || commit.Files.Count > 0) { - words.Add(new ChatMessage(ChatRole.Assistant, heard)); + ChatMessage reply = new(ChatRole.Assistant, heard.Length > 0 ? heard : null); + foreach (FileContent file in commit.Files) + { + reply.Contents.Add(file); + } + + words.Add(reply); } return words; @@ -37,7 +44,7 @@ public static List Compose(TurnCommit commit, List sta if (commit.Cut is null && commit.Completed) { - words.AddRange(staged.Count > 0 ? staged : source); + words.AddRange(asked.Count > 0 ? asked : source); return words; } @@ -69,7 +76,7 @@ public static List Compose(TurnCommit commit, List sta /// /// 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 + /// of every assistant message but the lines, 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. @@ -77,11 +84,44 @@ public static List Compose(TurnCommit commit, List sta public static string Spoken(IEnumerable messages) { return string.Concat(messages - .Where(message => message.Role == ChatRole.Assistant) + .Where(FrontVoice.IsAgentReply) .SelectMany(message => message.Contents.OfType()) .Select(text => text.Text)); } + private static IList WithoutUnasked(TurnCommit commit, IList messages) + { + if (commit.Unasked.Count == 0) + { + return messages; + } + + List kept = []; + foreach (ChatMessage message in messages) + { + if (!message.Contents.Any(content => IsUnasked(commit, content))) + { + kept.Add(message); + continue; + } + + List rest = [.. message.Contents.Where(content => !IsUnasked(commit, content))]; + if (rest.Count > 0) + { + ChatMessage trimmed = message.Clone(); + trimmed.Contents = rest; + kept.Add(trimmed); + } + } + + return kept; + } + + private static bool IsUnasked(TurnCommit commit, AIContent content) + { + return content is ToolApprovalRequestContent { ToolCall: { } call } && commit.Unasked.Contains(call.CallId); + } + /// Whether a message still says or does anything once its words are cut. internal static bool Carries(ChatMessage message) { diff --git a/src/AgentCore.AspNetCore/Calls/CallAnswer.cs b/src/AgentCore.AspNetCore/Calls/CallAnswer.cs new file mode 100644 index 00000000..9861b752 --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallAnswer.cs @@ -0,0 +1,28 @@ +namespace AgentCore.AspNetCore.Calls +{ + /// What one ask of a call came to. + internal enum CallAnswerKind + { + /// The engine answered; the vendor speaks the text. + Answered, + + /// A newer ask replaced this one, or the call ended: nothing is sent. + Withdrawn, + + /// The turn could not run; the vendor speaks the fallback reply. + Fallback, + } + + /// The answer of one ask. + /// What the ask came to. + /// What the vendor speaks, or empty for . + internal sealed record CallAnswer(CallAnswerKind Kind, string Text) + { + internal static CallAnswer Withdrawn { get; } = new(CallAnswerKind.Withdrawn, string.Empty); + + internal static CallAnswer Answered(string text) => new(CallAnswerKind.Answered, text); + + /// The answer of an ask that could not run: the vendor speaks . + internal static CallAnswer FallbackOf(string fallbackReply) => new(CallAnswerKind.Fallback, fallbackReply); + } +} diff --git a/src/AgentCore.AspNetCore/Calls/CallAsk.cs b/src/AgentCore.AspNetCore/Calls/CallAsk.cs new file mode 100644 index 00000000..29b928b5 --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallAsk.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Calls +{ + /// One ask that has no answer sent yet. + internal sealed class CallAsk(string words, IReadOnlyList before, CancellationTokenSource stop) + { + private readonly TaskCompletionSource _done = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the words this ask's turn carries. + internal string Words => words; + + /// Gets what was said ahead of that no turn answered, which the turn writes before them. + internal IReadOnlyList Before => before; + + internal CancellationToken Token => stop.Token; + + /// Cancels the ask, and waits up to for its turn to seal. + /// when the turn was still running after the wait. + internal async Task StopAsync(TimeSpan wait, TimeProvider time) + { + try + { + await stop.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The ask already finished; there is nothing left to cancel. + } + + try + { + await _done.Task.WaitAsync(wait, time).ConfigureAwait(false); + return true; + } + catch (TimeoutException) + { + return false; + } + } + + internal void Finish() + { + _ = _done.TrySetResult(); + stop.Dispose(); + } + } +} diff --git a/src/AgentCore.AspNetCore/Calls/CallAsks.cs b/src/AgentCore.AspNetCore/Calls/CallAsks.cs new file mode 100644 index 00000000..98269bb7 --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallAsks.cs @@ -0,0 +1,222 @@ +using System.Text; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.AspNetCore.Calls +{ + /// + /// The engine turns of a call whose vendor takes turns itself, and the correction rule: a + /// new ask while an older one has no answer yet replaces it. Asks take their place in the order they arrive. + /// + internal sealed class CallAsks(PhoneCall call) + { + /// How long a new ask waits for the older ask it cancelled to seal. The engine itself would wait 15 s. + internal static readonly TimeSpan OlderAskWait = TimeSpan.FromSeconds(5); + + private readonly Lock _gate = new(); + + private CallAsk? _open; + + private bool _stopped; + + private string? _anchor; + + private bool _anchored; + + private int _answeredTurn = -1; + + /// + /// Gets the turn of the last ask that was answered, or before the first. The vendor's + /// agent lines after that answer speak it. + /// + internal int? AnsweredTurn => Volatile.Read(ref _answeredTurn) is >= 0 and var turn ? turn : null; + + /// Runs one turn on the caller's latest words, and returns what the vendor should say. + /// The vendor's id of this ask; it becomes the user message's id. + /// The caller's request this ask is for. + /// + /// What was said since the last ask ahead of that no turn answered, oldest + /// first: the caller's words, and the vendor's own replies as lines. + /// + /// Told the name of each tool the turn calls, as it is called. + /// The call's token. + /// + /// Another call took the conversation after an idle unload; the vendor hangs this call up. + /// + internal async Task AskAsync( + string askId, string callerWords, IReadOnlyList before, Func onToolCall, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(askId); + ArgumentException.ThrowIfNullOrEmpty(callerWords); + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(onToolCall); + + if (call.HasEnded || !call.HasStarted) + { + return Fallback(); + } + + CallAsk mine; + CallAsk? older; + ConversationTurnOrigin origin; + lock (_gate) + { + if (_stopped) + { + return CallAnswer.Withdrawn; + } + + older = _open; + (mine, origin) = Open(call.Session, older, askId, callerWords, before, cancellationToken); + _open = mine; + } + + try + { + if (older is not null) + { + await ReplaceAsync(older).ConfigureAwait(false); + } + + ConversationSession session; + try + { + session = await call.LiveSessionAsync(mine.Token).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not CallConversationLostException) + { + return Failed(mine, fault); + } + + return await RunAsync(session, mine, origin, onToolCall).ConfigureAwait(false); + } + finally + { + lock (_gate) + { + if (ReferenceEquals(_open, mine)) + { + _open = null; + } + } + + mine.Finish(); + } + } + + /// Withdraws the open ask and refuses every later one: the call ended. + internal async Task StopAsync() + { + CallAsk? open; + lock (_gate) + { + _stopped = true; + open = _open; + } + + if (open is not null && !await open.StopAsync(OlderAskWait, call.Host.Time).ConfigureAwait(false)) + { + CallLog.AskLingeredAtEnd(call.Host.Logger, call.CallId, OlderAskWait.TotalSeconds); + } + } + + // A stopped or replaced ask stays silent whatever it threw. Any other fault, a timeout's + // OperationCanceledException too, is answered with the fallback, as FallbackAgent.IsFault rules. + private CallAnswer Failed(CallAsk mine, Exception fault) + { + if (mine.Token.IsCancellationRequested) + { + return CallAnswer.Withdrawn; + } + + CallLog.AskFailed(call.Host.Logger, call.CallId, fault); + return Fallback(); + } + + // The engine's own fallback for this entry: the entry's fallbackReply, else the document's. + private CallAnswer Fallback() => CallAnswer.FallbackOf(call.Session.Compiled.FallbackReply); + + // Every ask hangs off the last message the caller heard an answer to, so a resend on the same + // parent withdraws an unanswered ask, whether or not its turn filed a row yet. The first anchor is the last + // stored message: a null parent would truncate the whole conversation. + private (CallAsk Ask, ConversationTurnOrigin Origin) Open( + ConversationSession session, CallAsk? older, string askId, string callerWords, IReadOnlyList before, CancellationToken cancellationToken) + { + if (!_anchored) + { + _anchor = session.Transcript is [.., { MessageId: { } last }] ? last : null; + _anchored = true; + } + + string words = older is null ? callerWords : older.Words + " " + callerWords; + CallAsk ask = new(words, older is null ? before : [.. older.Before, .. before], CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + return (ask, new ConversationTurnOrigin(askId, _anchor) { NamesParent = true }); + } + + private async Task ReplaceAsync(CallAsk older) + { + if (!await older.StopAsync(OlderAskWait, call.Host.Time).ConfigureAwait(false)) + { + CallLog.OlderAskLingered(call.Host.Logger, call.CallId, OlderAskWait.TotalSeconds); + } + } + + private async Task RunAsync(ConversationSession session, CallAsk mine, ConversationTurnOrigin origin, Func onToolCall) + { + StringBuilder reply = new(); + string? lastMessage = null; + int turnIndex; + try + { + // A tool the withdrawn ask left running finishes once. Its + // call and result are rows only once it finished, and this turn reads the rows once, as it starts, so it + // starts after them; else the model sees no result and runs the tool again. + await session.ToolRuns.WhenCarriedDone().WaitAsync(mine.Token).ConfigureAwait(false); + + await using TurnRun run = await session.Stream.StartTurnAsync(new ChatMessage(ChatRole.User, mine.Words), origin, mine.Before, mine.Token).ConfigureAwait(false); + turnIndex = run.TurnIndex; + await foreach (ChatResponseUpdate update in run.Updates.ConfigureAwait(false)) + { + foreach (AIContent content in update.Contents) + { + switch (content) + { + case TurnCommittedContent committed: + lastMessage = committed.ReplyMessageId ?? committed.UserMessageId; + break; + case FunctionCallContent tool: + await onToolCall(tool.Name).ConfigureAwait(false); + break; + case TextContent text: + _ = reply.Append(text.Text); + break; + default: + break; + } + } + } + } + catch (Exception fault) when (fault is not CallConversationLostException) + { + return Failed(mine, fault); + } + + lock (_gate) + { + if (!ReferenceEquals(_open, mine)) + { + return CallAnswer.Withdrawn; + } + + _open = null; + _anchor = lastMessage ?? _anchor; + Volatile.Write(ref _answeredTurn, turnIndex); + } + + return reply.Length == 0 ? Fallback() : CallAnswer.Answered(reply.ToString()); + } + } +} diff --git a/src/AgentCore.AspNetCore/Calls/CallConversationLostException.cs b/src/AgentCore.AspNetCore/Calls/CallConversationLostException.cs new file mode 100644 index 00000000..dd70fd92 --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallConversationLostException.cs @@ -0,0 +1,9 @@ +namespace AgentCore.AspNetCore.Calls +{ + /// + /// The call's conversation was unloaded and another call now holds it (one live call per conversation). The call + /// can no longer ask anything; its vendor hangs it up. + /// + internal sealed class CallConversationLostException(string callId, string conversationId) + : InvalidOperationException($"the call {callId} lost conversation {conversationId} to another call."); +} diff --git a/src/AgentCore.AspNetCore/Calls/CallEndNotice.cs b/src/AgentCore.AspNetCore/Calls/CallEndNotice.cs new file mode 100644 index 00000000..a748b48f --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallEndNotice.cs @@ -0,0 +1,102 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Calls +{ + /// + /// Raises one call's once: when the call has started and has begun to leave, + /// whichever comes last. A call that never started raises none, as it raised no . + /// + internal sealed class CallEndNotice(string callId, TimeProvider time, ILogger logger) + { + private DateTimeOffset _startedAt; + + private int _started; + + private Leave? _leave; + + private int _raised; + + /// The call started; its length runs from . + internal void Started(DateTimeOffset startedAt) + { + _startedAt = startedAt; + _ = Interlocked.Exchange(ref _started, 1); + TryRaise(); + } + + /// The call began to leave. Only the first leave counts. + /// The session whose hooks hear of it. + /// How the call left. + /// The transport's word for why, or . + internal void Left(ConversationSession on, CallEndReason reason, string? cause) + { + if (Volatile.Read(ref _leave) is not null) + { + return; + } + + DateTimeOffset at; + try + { + at = time.GetUtcNow(); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + Lose(fault); + return; + } + + _ = Interlocked.CompareExchange(ref _leave, new Leave(on, reason, cause, at), null); + TryRaise(); + } + + /// + /// The call began to leave its own session, or found that the id moved on to : a newer + /// session another call holds hears it as , one no call holds as closed. + /// + internal void Left(ConversationSession? holder, ConversationSession own, CallEndReason ownReason, string? cause) + { + if (holder is null || ReferenceEquals(holder, own)) + { + Left(own, ownReason, cause); + } + else + { + Left(holder, holder.Lifetime.Ending.HasCall ? CallEndReason.Lost : CallEndReason.Closed, cause); + } + } + + // Both sides write with a full fence before they read the other's, so at least one of them sees both. + private void TryRaise() + { + if (Volatile.Read(ref _started) == 0 || Volatile.Read(ref _leave) is not { } leave || Interlocked.Exchange(ref _raised, 1) == 1) + { + return; + } + + try + { + SessionHooks hooks = leave.On.Hooks; + CallEnded ended = new(hooks.Scope(turnIndex: null, stage: null), callId, (leave.At - _startedAt).TotalSeconds, leave.Cause, leave.Reason); + leave.On.Lifetime.Ending.RaiseAfterEnd(ended); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + CallLog.CallEndNoticeLost(logger, callId, fault); + } + } + + private void Lose(Exception fault) + { + if (Interlocked.Exchange(ref _raised, 1) == 0) + { + CallLog.CallEndNoticeLost(logger, callId, fault); + } + } + + private sealed record Leave(ConversationSession On, CallEndReason Reason, string? Cause, DateTimeOffset At); + } +} diff --git a/src/AgentCore.AspNetCore/Calls/CallLog.cs b/src/AgentCore.AspNetCore/Calls/CallLog.cs new file mode 100644 index 00000000..f9b807dd --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallLog.cs @@ -0,0 +1,42 @@ +using AgentCore.Application.Hooks.Gates; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Calls +{ + /// The call core's log lines. None carries what anyone said. + internal static partial class CallLog + { + [LoggerMessage(EventId = 300, Level = LogLevel.Information, Message = "the {Transport} call {CallId} was refused: {Refusal}.")] + internal static partial void CallRefused(ILogger logger, string callId, string transport, CallRefusal refusal); + + [LoggerMessage(EventId = 301, Level = LogLevel.Warning, Message = "the call {CallId} named conversation {ConversationId}, which an entry other than {Entry} holds, so it was refused as busy.")] + internal static partial void ConversationInUse(ILogger logger, string callId, string conversationId, string entry); + + [LoggerMessage(EventId = 302, Level = LogLevel.Error, Message = "ending the call {CallId} on conversation {ConversationId} failed.")] + internal static partial void CallEndFaulted(ILogger logger, string callId, string conversationId, Exception fault); + + [LoggerMessage(EventId = 303, Level = LogLevel.Warning, Message = "reading the session of conversation {ConversationId} failed; the call keeps its own.")] + internal static partial void SessionTouchFaulted(ILogger logger, string conversationId, Exception fault); + + [LoggerMessage(EventId = 304, Level = LogLevel.Warning, Message = "an ask of the call {CallId} failed, so the caller hears the fallback reply.")] + internal static partial void AskFailed(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 305, Level = LogLevel.Warning, Message = "the replaced ask of the call {CallId} did not stop within {Seconds} s; the new one starts anyway.")] + internal static partial void OlderAskLingered(ILogger logger, string callId, double seconds); + + [LoggerMessage(EventId = 306, Level = LogLevel.Warning, Message = "the call {CallId} named conversation {ConversationId}, which another call holds, so it was refused as busy.")] + internal static partial void ConversationHeldByCall(ILogger logger, string callId, string conversationId); + + [LoggerMessage(EventId = 307, Level = LogLevel.Warning, Message = "the call {CallId} lost conversation {ConversationId} to another call, so its spoken lines are dropped.")] + internal static partial void LinesDropped(ILogger logger, string callId, string conversationId); + + [LoggerMessage(EventId = 308, Level = LogLevel.Warning, Message = "the open ask of the call {CallId} did not stop within {Seconds} s of the call's end; the end goes on anyway.")] + internal static partial void AskLingeredAtEnd(ILogger logger, string callId, double seconds); + + [LoggerMessage(EventId = 309, Level = LogLevel.Information, Message = "a newer connection of the call {CallId} took conversation {ConversationId} over, so the older one is dropped.")] + internal static partial void CallReplaced(ILogger logger, string callId, string conversationId); + + [LoggerMessage(EventId = 323, Level = LogLevel.Warning, Message = "the end notice of the call {CallId} could not be raised, so the hooks never hear it.")] + internal static partial void CallEndNoticeLost(ILogger logger, string callId, Exception fault); + } +} diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs new file mode 100644 index 00000000..c7c3672c --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs @@ -0,0 +1,395 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Conversation; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Domain.Audit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Calls +{ + /// + /// One phone call between a voice vendor and the engine. A vendor adapter turns its wire + /// into these calls; this class knows no wire and no CRM. + /// + internal sealed class PhoneCall + { + private readonly CallOffer _offer; + + private readonly Action? _replaced; + + private readonly Action _holder; + + private readonly CallEndNotice _end; + + private ConversationSession _session; + + private DateTimeOffset? _keptStart; + + private StrongBox? _startedAt; + + private int _startCalled; + + private int _ended; + + private int _linesDropped; + + private int _wasReplaced; + + private PhoneCall(PhoneCallHost host, CallOffer offer, string conversationId, string? brief, ConversationSession session, Action? replaced) + { + Host = host; + _offer = offer; + _replaced = replaced; + _holder = OnReplaced; + _end = new CallEndNotice(offer.CallId, host.Time, host.Logger); + _session = session; + ConversationId = conversationId; + Brief = brief; + Asks = new CallAsks(this); + } + + internal PhoneCallHost Host { get; } + + internal string CallId => _offer.CallId; + + internal string Entry => _offer.Entry; + + internal string ConversationId { get; } + + /// Gets the brief the hook accepted the call with, for the vendor's own voice; for none. + internal string? Brief { get; } + + internal ConversationSession Session => Volatile.Read(ref _session); + + internal DateTimeOffset StartedAt => Volatile.Read(ref _startedAt)?.Value ?? throw new InvalidOperationException("The transport has not taken this call yet."); + + internal bool HasEnded => Volatile.Read(ref _ended) == 1; + + /// Gets whether a newer call with this id took the conversation over; this call ends and closes nothing. + internal bool WasReplaced => Volatile.Read(ref _wasReplaced) == 1; + + /// + /// Gets whether the engine ended the call's conversation (a terminal stage, a tool) while the call is still up. + /// + internal bool EngineEnded => Session.Lifetime.Ending.Requested && !HasEnded; + + /// + /// Gets whether got past its last check and stamped the start time. The session's call + /// mark, the brief and may still be on their way. + /// + internal bool HasStarted => Volatile.Read(ref _startedAt) is not null; + + /// Gets the engine turns the vendor asks for, one per delegation. + internal CallAsks Asks { get; } + + /// + /// Runs the call gate, then opens the conversation the hook named. A conversation that + /// a call with another id holds refuses the call as busy: one live call per conversation. A live call + /// with the same id is taken over, as in LiveKit; the older call's replaced then runs on this admission, + /// so the vendor can drop its connection, and must not block. + /// + internal static async ValueTask AdmitAsync(PhoneCallHost host, CallOffer offer, CancellationToken cancellationToken, Action? replaced = null) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(offer); + + CallDecision decision = await CallGateChain.DecideAsync(host.Hooks, offer, cancellationToken, host.AnswerDeadline).ConfigureAwait(false); + if (!decision.Accepted || decision.ConversationId is not { } conversationId) + { + CallRefusal refusal = decision.Refusal ?? CallRefusal.Unavailable; + CallLog.CallRefused(host.Logger, offer.CallId, offer.Transport, refusal); + return PhoneCallAdmission.Refused(refusal); + } + + ConversationSession session; + try + { + session = await host.Sessions.GetOrOpenAsync(offer.Entry, conversationId, state: null, cancellationToken).ConfigureAwait(false); + } + catch (ConversationInUseException) + { + CallLog.ConversationInUse(host.Logger, offer.CallId, conversationId, offer.Entry); + return PhoneCallAdmission.Refused(CallRefusal.Busy); + } + + PhoneCall call = new(host, offer, conversationId, decision.Brief, session, replaced); + if (!session.Lifetime.Ending.ClaimCall(offer.CallId, call._holder, out call._keptStart)) + { + CallLog.ConversationHeldByCall(host.Logger, offer.CallId, conversationId); + return PhoneCallAdmission.RefusedHeldByCall(); + } + + return PhoneCallAdmission.Admitted(call); + } + + /// + /// The transport took the call: opens the store, marks the session as the call's, takes over the brief of the + /// conversation, and raises . A call starts once, and never after it left. When the + /// admitted session unloaded, the call starts on its conversation reopened. A call that took over a started + /// call with its id keeps that start and raises no second : it is the same call. + /// + /// The call already started, or it ended or was abandoned, also while the store opened. + /// + /// Another call took the conversation after an unload, or a newer call with this id took it over. Its session + /// and its brief are left untouched. + /// + internal async ValueTask StartAsync(CancellationToken cancellationToken) + { + if (HasEnded) + { + throw new InvalidOperationException("The call has already left."); + } + + if (Interlocked.Exchange(ref _startCalled, 1) == 1) + { + throw new InvalidOperationException("The call has already started."); + } + + ConversationSession session = await Host.Sessions.GetOrOpenAsync(Entry, ConversationId, state: null, cancellationToken).ConfigureAwait(false); + if (!ReferenceEquals(session, Session)) + { + if (!session.Lifetime.Ending.ClaimCall(CallId, _holder, out DateTimeOffset? kept)) + { + throw new CallConversationLostException(CallId, ConversationId); + } + + _keptStart = kept; + Volatile.Write(ref _session, session); + } + + // ConversationStarted is raised when the store opens, so it always comes before CallStarted. + _ = await session.Ledger.OpenSessionAsync(cancellationToken).ConfigureAwait(false); + + if (HasEnded) + { + throw new InvalidOperationException("The call left while its conversation opened."); + } + + DateTimeOffset startedAt = _keptStart ?? Host.Time.GetUtcNow(); + if (!session.Lifetime.Ending.TryMarkCall(CallId, _holder, startedAt)) + { + throw new CallConversationLostException(CallId, ConversationId); + } + + Volatile.Write(ref _startedAt, new StrongBox(startedAt)); + + // The brief is filed under the conversation id, so a call with none must not inherit an earlier call's. + if (string.IsNullOrEmpty(Brief) && !Host.FrontVoice) + { + Host.Briefs?.Clear(ConversationId); + } + else + { + Host.Briefs?.Set(ConversationId, string.IsNullOrEmpty(Brief) ? null : Brief, Host.FrontVoice); + } + + // An end that landed after the check above may have forgotten the brief before it was set here, and it + // raises its own end: the call does not start. + if (HasEnded) + { + Host.Briefs?.Clear(ConversationId); + return; + } + + if (_keptStart is null) + { + SessionHooks hooks = session.Hooks; + _ = hooks.Raise(new CallStarted(hooks.Scope(turnIndex: null, stage: null), CallId, _offer.From, _offer.To, _offer.Transport)); + } + + _end.Started(startedAt); + } + + /// Lets go of an admitted call the transport never took. No call notice is raised. + internal async ValueTask AbandonAsync() + { + if (Interlocked.Exchange(ref _ended, 1) == 1) + { + return; + } + + await CloseAsync().ConfigureAwait(false); + } + + /// + /// Raises one closed spoken line as , and marks the conversation as still live. After an + /// unload, a session reopened with no call on it is taken over, as does; a line + /// heard after another call took the conversation is dropped (and logged once), never raised into that call's. + /// + /// Who spoke the line. + /// The line's words. + /// When the line began, or for now. + /// When the line ended, or for now. + /// + /// For an agent line, the turn whose reply it speaks; a caller line carries none. Either line still reaches + /// the hooks after : a spoken line is a record of the call. + /// + internal async ValueTask HeardAsync(Speaker speaker, string text, DateTimeOffset? startedAt = null, DateTimeOffset? endedAt = null, int? turnIndex = null) + { + ArgumentNullException.ThrowIfNull(text); + if (HasEnded || text.Length == 0) + { + return; + } + + ConversationSession target = Session; + ConversationSession? holder = await HolderAsync(target).ConfigureAwait(false); + if (holder is not null && !ReferenceEquals(holder, target)) + { + if (Volatile.Read(ref _startedAt) is not { } started || !TryAdopt(holder, started.Value)) + { + if (Interlocked.Exchange(ref _linesDropped, 1) == 0) + { + CallLog.LinesDropped(Host.Logger, CallId, ConversationId); + } + + return; + } + + target = holder; + } + + DateTimeOffset now = Host.Time.GetUtcNow(); + SessionHooks hooks = target.Hooks; + int? lineTurn = speaker == Speaker.Agent ? turnIndex : null; + _ = hooks.Raise(new LineSpoken(hooks.Scope(lineTurn, stage: null), speaker, text, startedAt ?? now, endedAt ?? now)); + } + + /// + /// Ends the call's conversation once and forgets its brief; a second end does nothing. The session stays + /// open: call . + /// + internal async ValueTask EndAsync(ConversationEndReason reason, string? cause) + { + if (Interlocked.Exchange(ref _ended, 1) == 1) + { + return; + } + + await Asks.StopAsync().ConfigureAwait(false); + + ConversationSession own = Session; + ConversationSession? holder = await HolderAsync(own).ConfigureAwait(false); + if (!Leaves(holder, own)) + { + return; + } + + if (Owns(holder, own)) + { + try + { + _ = own.Lifetime.EndConversation(reason, cause); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + CallLog.CallEndFaulted(Host.Logger, CallId, ConversationId, fault); + } + } + + _end.Left(holder, own, CallEndReason.Ended, cause); + ForgetBrief(holder, own); + } + + /// + /// Closes the call's session with no end notice, unless the id moved on to a newer session or a newer call + /// took it over, and forgets the brief. Every way a call leaves comes through here or . + /// + internal async ValueTask CloseAsync() + { + ConversationSession own = Session; + ConversationSession? holder = await HolderAsync(own).ConfigureAwait(false); + if (!Leaves(holder, own)) + { + return; + } + + _end.Left(holder, own, CallEndReason.Closed, cause: null); + ForgetBrief(holder, own); + if (Owns(holder, own)) + { + await Host.Sessions.CloseAsync(Entry, ConversationId, CancellationToken.None).ConfigureAwait(false); + } + } + + /// + /// The session the next ask runs on: the held one, or the call's conversation reopened after an idle unload and + /// marked as the call's. A reopened session with no call on it is taken over. + /// + /// The transport has not taken this call yet. + /// + /// Another call took the conversation after the unload. That call, its end, and its brief are left untouched. + /// + internal async ValueTask LiveSessionAsync(CancellationToken cancellationToken) + { + DateTimeOffset startedAt = StartedAt; + ConversationSession live = await Host.Sessions.GetOrOpenAsync(Entry, ConversationId, state: null, cancellationToken).ConfigureAwait(false); + if (!ReferenceEquals(live, Session) && !TryAdopt(live, startedAt)) + { + throw new CallConversationLostException(CallId, ConversationId); + } + + return live; + } + + // Takes over the call's conversation reopened after an unload, unless another call holds it. + private bool TryAdopt(ConversationSession reopened, DateTimeOffset startedAt) + { + if (WasReplaced || !reopened.Lifetime.Ending.TryMarkCall(CallId, _holder, startedAt)) + { + return false; + } + + Volatile.Write(ref _session, reopened); + return true; + } + + private static bool Owns(ConversationSession? holder, ConversationSession own) + { + return holder is null || ReferenceEquals(holder, own); + } + + // Once the call lets go of its session, no newer call takes it over; one that did owns it, its end and brief. + private bool Leaves(ConversationSession? holder, ConversationSession own) + { + return !Owns(holder, own) || own.Lifetime.Ending.ReleaseCall(_holder); + } + + private void OnReplaced() + { + Volatile.Write(ref _wasReplaced, 1); + CallLog.CallReplaced(Host.Logger, CallId, ConversationId); + _end.Left(Session, CallEndReason.Replaced, cause: null); + _replaced?.Invoke(this); + } + + // The brief is filed under the conversation id. A newer session that another call holds carries that call's + // brief, so only then is it left in place. + private void ForgetBrief(ConversationSession? holder, ConversationSession own) + { + if (Owns(holder, own) || !holder!.Lifetime.Ending.HasCall) + { + Host.Briefs?.Clear(ConversationId); + } + } + + // The id may have moved on: this call's session unloaded and a later caller reopened it. Ending or closing that + // newer session would answer this call's hang-up with someone else's call. An unreadable store falls back to + // this call's own session. The lookup takes no token, so a cancellation here is the store's own timeout: a fault + // like any other. + private async ValueTask HolderAsync(ConversationSession own) + { + try + { + return await Host.Sessions.TryGetAsync(Entry, ConversationId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + CallLog.SessionTouchFaulted(Host.Logger, ConversationId, fault); + return own; + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs b/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs new file mode 100644 index 00000000..a01eb10b --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs @@ -0,0 +1,22 @@ +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.AspNetCore.Calls +{ + /// + /// The call gate's verdict on one offered call, after the session open. An admitted call must reach + /// or : until then it keeps its conversation busy. + /// + /// The admitted call, or . + /// Why the call is refused, or when it was admitted. + internal sealed record PhoneCallAdmission(PhoneCall? Call, CallRefusal? Refusal) + { + /// Gets whether the call was refused as busy because another call holds its conversation. + internal bool HeldByCall { get; private init; } + + internal static PhoneCallAdmission Admitted(PhoneCall call) => new(call, null); + + internal static PhoneCallAdmission Refused(CallRefusal refusal) => new(null, refusal); + + internal static PhoneCallAdmission RefusedHeldByCall() => new(null, CallRefusal.Busy) { HeldByCall = true }; + } +} diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs b/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs new file mode 100644 index 00000000..00100e33 --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs @@ -0,0 +1,48 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Calls +{ + /// What every call of one host shares. + /// The one session owner of the host. + /// The compiled hooks: the call gate and the notices. + /// The host's clock. + /// Where the call core logs. + /// How long each BeforeCall hook may take: providers.conversation.answerSeconds, else 5 s. + internal sealed record PhoneCallHost( + IConversationSessions Sessions, + HookRuntime Hooks, + TimeProvider Time, + ILogger Logger, + TimeSpan AnswerDeadline) + { + /// Gets the built-in brief hook, or in a host that registered no conversation adapter. + internal CallBriefHook? Briefs { get; } = Hooks.Table.Hooks.OfType().FirstOrDefault(); + + /// + /// Gets whether the vendor speaks for itself, so every turn of its calls is told the + /// . + /// + internal bool FrontVoice { get; init; } + + /// Reads the host's shared parts from a started container. + internal static PhoneCallHost From(IServiceProvider services, ConversationProviderConfiguration? configuration) + { + ArgumentNullException.ThrowIfNull(services); + + AgentCoreBoot boot = services.GetRequiredService(); + return new PhoneCallHost( + boot.Entries.Sessions, + boot.Hooks, + services.GetRequiredService(), + services.GetRequiredService().CreateLogger("AgentCore.Calls"), + configuration?.AnswerSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : GatePoint.BeforeCall.Deadline); + } + } +} diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs b/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs index 29a974a5..f7b1d1cc 100644 --- a/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs +++ b/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs @@ -1,4 +1,6 @@ +using AgentCore.Application.Hooks.Notices; using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.Application.Ports; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -43,6 +45,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) .ConfigureAwait(false); ConversationSweeperLog.Swept(logger, swept); + _ = HookStartup.RaiseHostNotice( + services.GetRequiredService().Hooks, scope => new RetentionSwept(scope, swept)); } catch (Exception fault) when (fault is not OperationCanceledException) { diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs index 49ce7901..10813f19 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs @@ -1,23 +1,28 @@ +using System.Diagnostics.CodeAnalysis; using AgentCore.Application.Audit; -using AgentCore.Application.Conversation; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; using AgentCore.Application.Evaluation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; using AgentCore.Application.Secrets; +using AgentCore.Application.Skills; using AgentCore.Application.Tools.Binding; using AgentCore.Application.Tools.Builtin; using AgentCore.Application.Tools.Registry; using AgentCore.AspNetCore.DependencyInjection.Startup; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.AI; -using AgentCore.Application.Skills; namespace AgentCore.AspNetCore.DependencyInjection { @@ -34,6 +39,8 @@ internal sealed class AgentCoreBoot : IAsyncDisposable, IDisposable private int _closed; + private BootState? _state; + /// Takes the options a host filled and the loggers the container holds. /// The options every Use* seam wrote into. /// The container's factory, used unless the options name another. @@ -108,7 +115,13 @@ public AgentCoreBoot(IOptions options, ILoggerFactory loggers, /// Gets the telemetry export, or when the host registered no vendor. internal ITelemetrySession? Telemetry => Started.Telemetry; - private BootState Started { get => field ?? throw NotStarted(); set; } + /// Gets the hooks every entry was compiled with. + internal HookRuntime Hooks => Started.Hooks; + + private BootState Started { get => _state ?? throw NotStarted(); set => _state = value; } + + /// Gets the hooks, or when the boot never finished: a stop after a failed boot. + internal bool TryGetHooks([NotNullWhen(true)] out HookRuntime? hooks) => (hooks = _state?.Hooks) is not null; /// Takes ownership of one resource, and hands it straight back. /// The resource's own type, so a caller loses nothing by owning it. @@ -235,6 +248,10 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken) .CreateRegistryAsync(configuration, _options, cancellationToken) .ConfigureAwait(false); + (IReadOnlyList hooks, QueuedAuditSink auditQueue) = await HookStartup + .OpenAsync(this, configuration, _options, _loggers, Services, cancellationToken) + .ConfigureAwait(false); + CompiledGraph graph = await CompilationStartup .CompileAsync( configuration, @@ -250,15 +267,18 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken) Loggers = _loggers, WorkspaceRoot = _options.WorkspaceRoot, Clock = _options.TimeProvider, + Hooks = hooks, Secrets = secrets, }) .ConfigureAwait(false); + HookRuntime runtime = Track(graph.Entries.Values.First().Hooks); + runtime.StopTimeout = Services?.GetService>()?.Value.ShutdownTimeout ?? HookRuntime.DefaultStopTimeout; + auditQueue.Upstream = runtime.Notices.FlushAllAsync; + ConversationSeamAdapters seams = ConversationSeamStartup.Build(configuration, _options); - ConversationSessionSeam conversation = await ConversationSessionStartup - .OpenAsync(this, configuration, _options, graph, _loggers, cancellationToken) - .ConfigureAwait(false); + ConversationSessionSeam conversation = ConversationSessionStartup.Open(this, _options, graph, auditQueue, _loggers); Started = new BootState( configuration, @@ -275,7 +295,8 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken) seams.Conversation, seams.Speech, seams.Handler, - seams.Unroutable); + seams.Unroutable, + runtime); } /// @@ -356,13 +377,14 @@ private sealed record BootState( IReadOnlyList? ConversationAdapters, IReadOnlyList? SpeechAdapters, RequestDelegate? ConversationHandler, - string? ConversationUnroutable); + string? ConversationUnroutable, + HookRuntime Hooks); } /// Every line writes itself, below what each startup step logs. internal static partial class AgentCoreBootLog { - /// One of section 8.5's structural warnings, or one of section 10's two (K33, K39). + /// One structural warning from the configuration checks. /// The boot's own logger. /// The warning's ToString(): its pointer, its message, and its check. [LoggerMessage( diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBootService.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBootService.cs index eb498f87..d89dc6b5 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBootService.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBootService.cs @@ -1,3 +1,6 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection.Startup; using Microsoft.Extensions.Hosting; namespace AgentCore.AspNetCore.DependencyInjection @@ -23,20 +26,27 @@ public Task StartAsync(CancellationToken cancellationToken) /// public Task StartedAsync(CancellationToken cancellationToken) { + if (boot.TryGetHooks(out HookRuntime? hooks)) + { + _ = HookStartup.RaiseHostNotice( + hooks, scope => new HostStarted(scope, [.. boot.Entries.Entries], boot.Tools.Ids.Count)); + } + return Task.CompletedTask; } /// public Task StoppingAsync(CancellationToken cancellationToken) { + if (boot.TryGetHooks(out HookRuntime? hooks)) + { + _ = HookStartup.RaiseHostNotice(hooks, scope => new HostStopping(scope)); + } + return Task.CompletedTask; } /// - /// - /// Nothing closes here. A failed start never calls StopAsync at all, so shutdown lives in - /// , which the container reaches on both paths. - /// public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs new file mode 100644 index 00000000..8812b6b0 --- /dev/null +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs @@ -0,0 +1,45 @@ +using AgentCore.Application.Hooks; +using Microsoft.Extensions.DependencyInjection; + +namespace AgentCore.AspNetCore.DependencyInjection +{ + /// Registers the host's hooks. AgentCore's own built-in hooks always run first. + public static class AgentCoreHookOptions + { + /// + /// Adds a hook resolved once from the container: the registered singleton when there is one, else an + /// instance built with constructor injection. One instance serves every conversation. + /// + /// The hook type. + /// The options. + /// The options, so a host chains its calls. + public static AgentCoreOptions UseHooks(this AgentCoreOptions options) + where THook : AgentHook + { + ArgumentNullException.ThrowIfNull(options); + + options.HookFactories.Add((typeof(THook), static services => services is null + ? throw new InvalidOperationException($"UseHooks<{typeof(THook).Name}>() needs the host's container, and this boot has none.") + : ActivatorUtilities.GetServiceOrCreateInstance(services))); + return options; + } + + /// Adds hook instances, in order. Calling it again adds more. + /// The options. + /// The hooks. One instance serves every conversation. + /// The options, so a host chains its calls. + public static AgentCoreOptions UseHooks(this AgentCoreOptions options, params AgentHook[] hooks) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(hooks); + + foreach (AgentHook hook in hooks) + { + ArgumentNullException.ThrowIfNull(hook, nameof(hooks)); + options.HookFactories.Add((hook.GetType(), _ => hook)); + } + + return options; + } + } +} diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs index 93b4b347..df8bb546 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs @@ -1,10 +1,11 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; using AgentCore.Application.Knowledge; using AgentCore.Application.Llm; using AgentCore.Application.Ports; using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; using Microsoft.Agents.AI; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; @@ -18,8 +19,6 @@ public sealed class AgentCoreOptions { private readonly List> _toolSources = []; - private readonly List _observers = []; - /// Gets the path of the configuration document, or . public string? ConfigurationPath { get; set; } @@ -103,8 +102,11 @@ public sealed class AgentCoreOptions /// Gets the extra tool sources, in the order the registry asks them. internal IReadOnlyList> ToolSources => _toolSources; - /// Gets the observers the host registered, in the order it registered them. - internal IReadOnlyList Observers => _observers; + /// + /// Gets the host's hooks, in registration order: each registration's type, and how to get its instance (the + /// instance itself, or a resolve from the container). + /// + internal List<(Type HookType, Func Create)> HookFactories { get; } = []; /// Gets the owner opener the host bound, or for the in-memory default. internal Func, IConversationSessions>? ConversationSessions { get; private set; } @@ -271,19 +273,6 @@ public AgentCoreOptions UseBlobStores(params IBlobStoreAdapter[] adapters) return this; } - /// Binds the host's own readings of a conversation, beside the three this library keeps. - /// - /// What the host wants told about every conversation. Each one takes every fact, in the order the conversation - /// produced it. An empty set is legal and registers nothing. - /// - /// These options, so a host chains its conversations. - public AgentCoreOptions UseObservers(params IConversationObserver[] observers) - { - ArgumentNullException.ThrowIfNull(observers); - _observers.AddRange(observers); - return this; - } - /// Binds the speech vendors, and the document picks one by kind. /// The vendors this host supports. /// These options, so a host chains its conversations. diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs index 5d5c5aa5..97b11e42 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using AgentCore.AspNetCore.Conversation; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Sessions; +using AgentCore.AspNetCore.Vendors.OpenAiLive; using Microsoft.Agents.AI.Hosting; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.WebSockets; @@ -69,6 +70,10 @@ public static IServiceCollection AddAgentCore( _ = services.AddHostedService(); + // after the boot service: its StartAsync runs once the boot (StartingAsync) is done + _ = services.AddSingleton(); + _ = services.AddHostedService(provider => provider.GetRequiredService()); + _ = services.AddSingleton(Boot(boot => boot.Telemetry!)); _ = services.AddSingleton(Boot(boot => boot.Knowledge!)); _ = services.AddSingleton(Boot(boot => boot.Blobs!)); diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs index 63ca2f3a..f81a6da4 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs @@ -1,7 +1,10 @@ +using System.Text.Json; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Providers; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Routing; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.DependencyInjection.Startup @@ -56,13 +59,22 @@ internal static ConversationSeamAdapters Build( IConversationAdapter selectedConversation = VendorAdapterSelector.Select( conversationEntry.Kind, conversationAdapters, ConversationSeams.Conversation); - SpeechProviderConfiguration speechEntry = configuration.Providers?.Speech - ?? throw MissingSpeechBlock(); + if (selectedConversation.Traits.HasFlag(CallTraits.TakesTurnsItself)) + { + RefuseOwnTurnTaking(conversationEntry, selectedConversation.Kind); + } // Read here, and not only where the route is mapped. Agreement between two document entries // is a document fact: it is true or false whether or not anything is routed, so a host that // forgets app.MapCall() still learns its document contradicts itself. - ConversationSpeechPairing.Validate(conversationEntry, speechEntry, selectedConversation); + if (configuration.Providers?.Speech is { } speechEntry) + { + ConversationSpeechPairing.Validate(conversationEntry, speechEntry, selectedConversation); + } + else if (!selectedConversation.Traits.HasFlag(CallTraits.SpeaksForItself)) + { + throw MissingSpeechBlock(); + } // Built here and not where the route is mapped, so an unusable limit in providers.conversation stops // the host rather than the first conversation that arrives on it. A vendor this process dials out to @@ -76,6 +88,38 @@ internal static ConversationSeamAdapters Build( : new ConversationSeamAdapters(conversationAdapters, options.Speech, transport.CreateHandler(conversationEntry), null); } + /// Refuses the knobs of AgentCore's own turn taking for a vendor that takes turns itself. + private static void RefuseOwnTurnTaking(ConversationProviderConfiguration conversation, string kind) + { + List errors = []; + if (conversation.Filler.Count > 0) + { + errors.Add(OwnTurnTaking("filler", kind)); + } + + if (conversation.UserAway.ValueKind != JsonValueKind.Undefined) + { + errors.Add(OwnTurnTaking("userAway", kind)); + } + + if (errors.Count > 0) + { + throw new ConfigurationLoadException(errors); + } + } + + private static ConfigurationError OwnTurnTaking(string field, string kind) + { + return new ConfigurationError + { + Pointer = $"/providers/conversation/{field}", + Message = + $"providers.conversation is kind: {kind}, and that vendor decides when the caller's turn ends, so " + + $"AgentCore's own turn taking never runs and providers.conversation.{field} would do nothing. Remove it.", + Check = ConfigurationCheck.ReferenceResolution, + }; + } + /// Refuses a configuration that turned the conversation seam on and named no transport. /// The failure to throw, pointed at the block that is missing. private static ConfigurationLoadException MissingConversationBlock() @@ -86,8 +130,8 @@ private static ConfigurationLoadException MissingConversationBlock() Message = "This host registered a conversation transport with options.UseConversation(...), and this " + "configuration writes no providers.conversation block for a kind to be picked from. A " - + "document that writes a providers section names both conversation and speech, because the " - + "schema requires them there; a document that writes no providers section at all is " + + "document that writes a providers section names a conversation block, because the " + + "schema requires it there; a document that writes no providers section at all is " + "valid, and so is a configuration a host built in code, which passes through no " + "schema. Write providers.conversation: { kind: ... }, or drop the options.UseConversation(...) call.", Check = ConfigurationCheck.ReferenceResolution, @@ -105,9 +149,7 @@ private static ConfigurationLoadException MissingSpeechBlock() "This configuration names providers.conversation and no providers.speech, so there is " + "nothing to check the transport against: a transport that carries text is itself " + "the recognizer, and one that carries audio needs a speech vendor named. A " - + "document that writes a providers section names both, because the schema requires " - + "them there; a document that writes no providers section at all is valid, and so " - + "is a configuration a host built in code, which passes through no schema. Write " + + "transport that does not speak for itself needs a speech vendor named. Write " + "providers.speech with both of its roles: stt: { kind: ... } and tts: { kind: ... }.", Check = ConfigurationCheck.ReferenceResolution, }); diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs index 4950ca7f..4423284e 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs @@ -1,11 +1,10 @@ using AgentCore.Application.Audit; -using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.DependencyInjection.Startup { @@ -15,45 +14,25 @@ namespace AgentCore.AspNetCore.DependencyInjection.Startup internal readonly record struct ConversationSessionSeam( EntryRegistry Entries, QueuedAuditSink Queue); - /// The seam a conversation arrives on: the audit queue, the observers, and one session factory per entry. + /// The seam a conversation arrives on: the audit queue and one session factory per entry. internal static class ConversationSessionStartup { - /// 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. - /// The compiled entries and the seams step 5 made. - /// The factory the session and the audit queue take their loggers from. - /// Cancels the store open. + /// Builds the one session owner over every entry's factory. + /// The owner the session owner is tracked against. + /// The options the host filled. It carries the clock and the workspace. + /// The compiled entries and the seams built from them. + /// The queue that answers the audit port, opened before the compile. + /// The factory the session takes its logger from. /// The per-entry seams, and the queue in front of the store. - internal static async ValueTask OpenAsync( + internal static ConversationSessionSeam Open( AgentCoreBoot boot, - AgentCoreConfiguration configuration, AgentCoreOptions options, CompiledGraph graph, - ILoggerFactory loggers, - CancellationToken cancellationToken) + QueuedAuditSink auditSink, + ILoggerFactory loggers) { - IAuditSinkPort store = boot.Track(await AuditSinkFactory - .OpenAsync( - configuration, - options.SecretResolver, - options.AuditSinks ?? [], - cancellationToken) - .ConfigureAwait(false)); - - if (store is InMemoryAuditSink && configuration.Providers?.Audit is null) - { - StartupLog.AuditSinkDefaulted(loggers.CreateLogger()); - } - TimeProvider timeProvider = options.TimeProvider ?? TimeProvider.System; - QueuedAuditSink auditSink = boot.Track(new QueuedAuditSink( - store, - loggers.CreateLogger(), - timeProvider: timeProvider)); - ILogger sessionLogger = loggers.CreateLogger(); if (options.WorkspaceRoot is { } root) @@ -72,8 +51,6 @@ internal static async ValueTask OpenAsync( } } - IReadOnlyList observers = ConversationObservers.Standard(auditSink, sessionLogger, options.Observers); - Dictionary factories = new(StringComparer.Ordinal); foreach ((string? entryName, Application.Configuration.Compilation.CompiledAgent? compiled) in graph.Entries) @@ -84,8 +61,7 @@ internal static async ValueTask OpenAsync( ConversationSessionFactory.CreateExtractor(compiled, graph.ChatClients), options.TimeProvider, sessionLogger, - observers, - options.WorkspaceRoot); + workspaceRoot: options.WorkspaceRoot); } IConversationSessions sessions = options.ConversationSessions?.Invoke(factories) diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationStartup.cs index 072f4738..bc683d62 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationStartup.cs @@ -6,7 +6,7 @@ namespace AgentCore.AspNetCore.DependencyInjection.Startup { - /// Step 4c: open the store 0 backing the document names, before the document is compiled. + /// Opens the conversation store the document names, before the document is compiled. internal static class ConversationStartup { /// Opens the store providers.conversations names, or the built-in one. diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs index f1867629..ec08c7c9 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs @@ -4,14 +4,6 @@ namespace AgentCore.AspNetCore.DependencyInjection.Startup { /// 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. - /// 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 and the one session owner. diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs index 393a71f5..1df9a617 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs @@ -1,16 +1,16 @@ using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; using AgentCore.AspNetCore.Endpoints; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace AgentCore.AspNetCore.DependencyInjection.Startup { - /// - /// Fails startup when an AgentCore route has no way to learn its entry, or names a selector the host never - /// registered. - /// + /// Fails startup when an AgentCore route has no way to learn its entry. internal sealed class EntryRouteCheck : IStartupFilter { /// @@ -25,7 +25,7 @@ public Action Configure(Action next) /// Walks every mapped route AgentCore tagged. /// The root provider. - /// A route names no entry, or its selector is not registered. + /// A route names no entry and no hook overrides the entry gate. internal static void Check(IServiceProvider services) { if (services.GetService() is not { } routes) @@ -33,33 +33,23 @@ internal static void Check(IServiceProvider services) return; } - IServiceProviderIsService registered = services.GetRequiredService(); + bool gated = services.GetRequiredService>().Value.HookFactories + .Any(static entry => HookTable.TypeOverrides(entry.HookType, GatePoint.BeforeEntry)); foreach (RouteEndpoint route in routes.Endpoints.OfType()) { - if (route.Metadata.GetMetadata() is null) + if (route.Metadata.GetMetadata() is null + || gated + || route.RoutePattern.GetParameter(ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter) is not null) { continue; } string pattern = route.RoutePattern.RawText ?? route.DisplayName ?? "(unnamed route)"; - - if (route.Metadata.GetMetadata() is { } selector) - { - if (!registered.IsService(selector.SelectorType)) - { - throw new ConfigurationLoadException( - $"The route '{pattern}' picks its entry with {selector.SelectorType.FullName}, which the " - + "host never registered. Add it to the service collection."); - } - } - else if (route.RoutePattern.GetParameter(ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter) is null) - { - throw new ConfigurationLoadException( - $"The route '{pattern}' carries no {{{ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter}}} " - + "parameter and no entry selector, so nothing names its entry. Add {entry} to the pattern, " - + "or call SelectEntry on the route."); - } + throw new ConfigurationLoadException( + $"The route '{pattern}' carries no {{{ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter}}} " + + $"parameter and no hook overrides {nameof(AgentHook.BeforeEntryAsync)}, so nothing names its entry. " + + "Add {entry} to the pattern, or register a hook with options.UseHooks(...) that chooses the entry."); } } } diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/HookStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/HookStartup.cs new file mode 100644 index 00000000..93f4245c --- /dev/null +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/HookStartup.cs @@ -0,0 +1,63 @@ +using AgentCore.Application.Audit; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Ports; +using Microsoft.Extensions.Logging; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.DependencyInjection.Startup +{ + /// Builds the hook list a compile runs, and raises the notices that belong to the host. + internal static class HookStartup + { + /// + /// Opens the audit store the document names and the queue in front of it, and lists the hooks a compile runs: + /// AgentCore's built-ins first (the call brief hook only when the host registers a conversation adapter), then + /// the host's, in registration order. The store and the queue are tracked before the compile's hooks, so a + /// shutdown drains the hooks first, then the queue, then the store. + /// + /// The owner the store and the queue are tracked against. + /// The loaded document. It carries providers.audit. + /// The options the host filled. It carries the audit vendors, the clock, and the hooks. + /// The factory the audit queue takes its logger from. + /// The container a host hook is resolved from, or . + /// Cancels the store open. + /// The hooks, and the queue that answers the audit port. + internal static async ValueTask<(IReadOnlyList Hooks, QueuedAuditSink AuditQueue)> OpenAsync( + AgentCoreBoot boot, + AgentCoreConfiguration configuration, + AgentCoreOptions options, + ILoggerFactory loggers, + IServiceProvider? services, + CancellationToken cancellationToken) + { + IAuditSinkPort store = boot.Track(await AuditSinkFactory + .OpenAsync(configuration, options.SecretResolver, options.AuditSinks ?? [], cancellationToken) + .ConfigureAwait(false)); + + if (store is InMemoryAuditSink && configuration.Providers?.Audit is null) + { + StartupLog.AuditSinkDefaulted(loggers.CreateLogger()); + } + + ILogger auditLogger = loggers.CreateLogger(); + QueuedAuditSink queue = boot.Track(new QueuedAuditSink(store, auditLogger, timeProvider: options.TimeProvider ?? TimeProvider.System)); + + // The call brief hook is registered only when the host registers a conversation adapter. + IReadOnlyList callHooks = options.ConversationAdapters is null ? [] : [new CallBriefHook()]; + return ([.. BuiltInHooks.Create(queue, loggers.CreateLogger()), .. callHooks, .. options.HookFactories.Select(entry => entry.Create(services))], queue); + } + + /// Raises a host notice, stamped by the clock the hooks' deadlines run on. + /// The compile's hooks. + /// Builds the notice from the host scope. + /// The notice's event id. + internal static Guid RaiseHostNotice(HookRuntime runtime, Func create) + { + return runtime.Notices.Raise(create(HookScopes.Host(runtime.Timers.GetUtcNow())), runtime.Timers); + } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs b/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs index 210031e6..4a83224b 100644 --- a/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs +++ b/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs @@ -1,4 +1,11 @@ +using System.Collections.Frozen; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.AspNetCore.DependencyInjection; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace AgentCore.AspNetCore.Endpoints @@ -7,61 +14,57 @@ namespace AgentCore.AspNetCore.Endpoints public static class AgentCoreEntries { /// - /// Runs the endpoint's selector, or reads {entry} when it has none, once per request, and keeps the - /// answer as . + /// Runs the entry gate once per request and keeps the answer: a hook's choice, else the route's + /// {entry} value. /// /// A request that routing has already matched to an endpoint. - /// Cancels the selector. - /// - /// The entry key, or when the selector refused. A route with no selector and no - /// {entry} value answers the empty string, which no entry declares. - /// + /// Cancels the gate. + /// The entry key, or when a hook refused or the route names no entry. /// Routing has not matched the request to an endpoint yet. public static async ValueTask ResolveAsync(HttpContext http, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(http); - if (http.Features.Get() is { } known) + if (http.Features.Get() is { } known) { return known.Entry; } - if (http.Features.Get() is not null) - { - return null; - } - Endpoint endpoint = http.GetEndpoint() ?? throw new InvalidOperationException( - "the request has no endpoint yet, so no route names its entry. Resolve the entry after " - + "app.UseRouting()."); + "the request has no endpoint yet, so no route names its entry. Resolve the entry after app.UseRouting()."); - string? entry = endpoint.Metadata.GetMetadata() is { } metadata - ? await ((IEntrySelector)http.RequestServices.GetRequiredService(metadata.SelectorType)) - .SelectAsync(http, cancellationToken) - .ConfigureAwait(false) - : http.Request.RouteValues[ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter] as string ?? string.Empty; + HookRuntime hooks = http.RequestServices.GetRequiredService().Hooks; + string? requested = http.Request.RouteValues[ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter] as string; - if (entry is null) + string? entry = requested; + if (hooks.Gates.Overrides(GatePoint.BeforeEntry)) { - http.Features.Set(EntryRefused.Instance); - } - else - { - http.Features.Set(new EntryFeature(entry)); + entry = await EntryGateChain.ChooseAsync(hooks, OfferOf(http, endpoint, requested), cancellationToken).ConfigureAwait(false); } + http.Features.Set(new EntryFeature(entry)); return entry; } - private sealed class EntryFeature(string entry) : IEntryFeature + /// Reads the entry this request already resolved, or . + /// The request. + /// The entry key, or when the gate has not run or refused. + internal static string? Known(HttpContext http) { - public string Entry { get; } = entry; + return http.Features.Get()?.Entry; } - private sealed class EntryRefused + private static EntryOffer OfferOf(HttpContext http, Endpoint endpoint, string? requested) { - public static EntryRefused Instance { get; } = new(); + return new EntryOffer( + http.User.Identity?.IsAuthenticated == true ? http.User : null, + http.Request.Headers.ToFrozenDictionary(static header => header.Key, static header => string.Join(',', header.Value.ToArray()), StringComparer.OrdinalIgnoreCase), + (endpoint as RouteEndpoint)?.RoutePattern.RawText ?? endpoint.DisplayName ?? string.Empty, + requested, + endpoint.Metadata.GetMetadata()?.Transport ?? EntryTransport.Http); } + + private sealed record EntryFeature(string? Entry); } } diff --git a/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs b/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs index 8bcb8011..235b32b7 100644 --- a/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs +++ b/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs @@ -1,15 +1,22 @@ +using AgentCore.Application.Hooks.Gates; + namespace AgentCore.AspNetCore.Endpoints { - /// - /// Tags a route AgentCore mapped, so the startup check finds every route that must learn its entry. - /// + /// Tags a route AgentCore mapped, with how requests reach it, so the startup check and the entry gate find it. internal sealed class AgentCoreRouteMetadata { - /// Gets the one tag every AgentCore route shares. - public static AgentCoreRouteMetadata Instance { get; } = new(); - - private AgentCoreRouteMetadata() + private AgentCoreRouteMetadata(EntryTransport transport) { + Transport = transport; } + + /// Gets the tag of an HTTP route (Responses). + public static AgentCoreRouteMetadata Http { get; } = new(EntryTransport.Http); + + /// Gets the tag of a call route (the conversation socket). + public static AgentCoreRouteMetadata Call { get; } = new(EntryTransport.Call); + + /// Gets how requests reach the route. + public EntryTransport Transport { get; } } } diff --git a/src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs b/src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs deleted file mode 100644 index 18e4c668..00000000 --- a/src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace AgentCore.AspNetCore.Endpoints -{ - /// Attaches an to a route AgentCore mapped. - public static class EntrySelectorEndpointConventionBuilderExtensions - { - /// Makes the route pick its entry with , in place of the URL. - /// The selector. The host registers it; startup fails when it does not. - /// The route, as a mapping call returned it. - /// The same route, so a host chains its conventions. - public static IEndpointConventionBuilder SelectEntry(this IEndpointConventionBuilder builder) - where TSelector : class, IEntrySelector - { - ArgumentNullException.ThrowIfNull(builder); - - return builder.WithMetadata(new EntrySelectorMetadata(typeof(TSelector))); - } - } -} diff --git a/src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs b/src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs deleted file mode 100644 index d7ee9016..00000000 --- a/src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace AgentCore.AspNetCore.Endpoints -{ - /// Endpoint metadata that names the a route picks its entry with. - /// The selector type, resolved from the request's services. - internal sealed record EntrySelectorMetadata(Type SelectorType); -} diff --git a/src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs b/src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs deleted file mode 100644 index 801e7a7c..00000000 --- a/src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace AgentCore.AspNetCore.Endpoints -{ - /// The entry this request runs, once it is known. - public interface IEntryFeature - { - /// Gets the entry key. - string Entry { get; } - } -} diff --git a/src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs b/src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs deleted file mode 100644 index 76fd856d..00000000 --- a/src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace AgentCore.AspNetCore.Endpoints -{ - /// - /// Picks the entry for one request on a route that carries this selector, so the server chooses the - /// agent and the URL does not. - /// - public interface IEntrySelector - { - /// Picks the entry this request runs. - /// The request, already authenticated when the host runs authentication first. - /// Cancels the pick. - /// The entry key, or to refuse the request with 403. - ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken); - } -} diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs index ebdc40a5..ef4926fb 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs @@ -1,8 +1,9 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using AgentCore.Application.Runtime; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.AspNetCore.Endpoints { diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs index 409204a8..2986c333 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs @@ -1,5 +1,6 @@ using System.Text.Json; using AgentCore.Application.Conversation; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.AspNetCore.DependencyInjection; @@ -12,6 +13,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Endpoints { @@ -47,9 +49,8 @@ public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder /// Maps every entry on one route. /// The route builder of the host. /// - /// The route to answer on. It carries the {entry} parameter, or the host attaches an - /// with . - /// Startup fails when it has neither. + /// The route to answer on. It carries the {entry} parameter, or a hook's + /// picks the entry. Startup fails when it has neither. /// /// The mapped endpoint, so a host adds its own conventions. public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder endpoints, string pattern) @@ -57,7 +58,7 @@ public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder ArgumentNullException.ThrowIfNull(endpoints); ArgumentException.ThrowIfNullOrEmpty(pattern); - return endpoints.MapPost(pattern, HandleAsync).WithMetadata(AgentCoreRouteMetadata.Instance); + return endpoints.MapPost(pattern, HandleAsync).WithMetadata(AgentCoreRouteMetadata.Http); } /// Runs one turn of one conversation, and files the session under the ids the answer carries. @@ -300,14 +301,14 @@ private static async Task RunTurnAsync( bool streaming = body.TryGetProperty("stream", out JsonElement streamFlag) && streamFlag.ValueKind == JsonValueKind.True; - string refusedReason = TurnRefusals.Busy; + string refusedReason = TurnRefusalTokens.ToToken(TurnRefusal.Busy); try { // 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; + refusedReason = TurnRefusalTokens.ToToken(TurnRefusal.Conflict); try { ChatMessage? input = pending.Approval is { } approval diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs index 85e8f893..43ac1edc 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs @@ -2,6 +2,7 @@ using AgentCore.AspNetCore.Sessions; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.AspNetCore.Endpoints { diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs index d404c13f..2df6a6ed 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs @@ -2,6 +2,8 @@ using AgentCore.AspNetCore.Sessions; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.AspNetCore.Endpoints { @@ -28,7 +30,7 @@ internal sealed record ResponsesTurn( /// 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. + /// response id, and the conversation store's own state outranks it on any later resume. /// public Task FileAsync(CancellationToken cancellationToken) { diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs index d23d1594..cef95546 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks.Notices; using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.Endpoints @@ -25,11 +25,11 @@ internal static class ResponsesTurnConflict "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. + /// 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; + refusedReason == TurnRefusalTokens.ToToken(TurnRefusal.Busy) ? BusyMessage : ConflictMessage; /// /// Ends a stream whose headers already left with the Responses error event, in place of the diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs index 94c06b92..765e137b 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs @@ -1,7 +1,6 @@ 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; @@ -11,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.AspNetCore.Endpoints { @@ -63,7 +63,7 @@ internal static async Task WriteAsync( } finally { - // The turn commits its words even when the host cancels mid-stream (design section 3), so the + // The turn commits its words even when the host cancels mid-stream, so the // session still needs filing here, on a token that outlives the abort that cancelled the one above. if (cancellationToken.IsCancellationRequested) { @@ -84,21 +84,17 @@ await FileAfterAbortAsync( /// /// Files the session of a turn whose client has gone, within - /// : the same bound the rest of the work after + /// : 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; + TimeSpan bound = TurnFailureReasons.CompletionTimeout; using CancellationTokenSource deadline = new(bound, time); try @@ -126,7 +122,7 @@ private static async IAsyncEnumerable StreamAgentUpdatesAsy { bool isNotice = update.Contents.OfType().Any(); - // Carries no text of its own (E5): the caller reads it off the agentcore_message_committed + // Carries no text of its own: the caller reads it off the agentcore_message_committed // dialect part instead, never as spoken or output text. bool isCommitted = update.Contents.OfType().Any(); diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt index 62090300..0256b749 100644 --- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt +++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt @@ -1,7 +1,7 @@ #nullable enable -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.Voice.Routing.ConversationEndpointRouteBuilderExtensions +AgentCore.AspNetCore.Voice.Ports.IConversationTransportAdapter +AgentCore.AspNetCore.Voice.Ports.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! @@ -28,7 +28,7 @@ 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.AspNetCore.Voice.IConversationAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversation(params AgentCore.AspNetCore.Voice.Ports.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! @@ -42,10 +42,9 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgeQueryAnaly AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgeRetrieval(System.Func! retrieval) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgeStores(params AgentCore.Application.Ports.IKnowledgeStoreAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseModeration(params AgentCore.Application.Ports.IModerationAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! -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.AspNetCore.Voice.ISpeechAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSpeech(params AgentCore.AspNetCore.Voice.Ports.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 @@ -70,8 +69,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.Voice.ConversationEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/call" -> string! -const AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter = "entry" -> string! +const AgentCore.AspNetCore.Voice.Routing.ConversationEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/call" -> string! +const AgentCore.AspNetCore.Voice.Routing.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! @@ -79,9 +78,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.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.Voice.Routing.ConversationEndpointRouteBuilderExtensions.EntryOf(Microsoft.AspNetCore.Http.HttpContext! http) -> string! +static AgentCore.AspNetCore.Voice.Routing.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +static AgentCore.AspNetCore.Voice.Routing.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! @@ -93,123 +92,139 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseBlobStores(params A const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.TimeZoneHeaderName = "X-AgentCore-Time-Zone" -> string! 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 -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 +AgentCore.AspNetCore.Voice.Routing.ConversationChannel +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.$() -> AgentCore.AspNetCore.Voice.Routing.ConversationChannel! +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.ConversationChannel(AgentCore.AspNetCore.Voice.Ports.IConversationInputPort! Input, AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort! Output) -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Deconstruct(out AgentCore.AspNetCore.Voice.Ports.IConversationInputPort! Input, out AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort! Output) -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Equals(AgentCore.AspNetCore.Voice.Routing.ConversationChannel? other) -> bool +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Input.get -> AgentCore.AspNetCore.Voice.Ports.IConversationInputPort! +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Input.init -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Output.get -> AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort! +AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Output.init -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.$() -> AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext! +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.ConversationChannelContext(string! ConversationId, System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.ConversationId.get -> string! +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.ConversationId.init -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.CustomParameters.get -> System.Collections.Generic.IReadOnlyDictionary? +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.CustomParameters.init -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.Deconstruct(out string! ConversationId, out System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void +AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.Equals(AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext? other) -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationInput +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.Barge(string! HeardText, System.TimeSpan PlayedDuration) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.Deconstruct(out string! HeardText, out System.TimeSpan PlayedDuration) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge? other) -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.HeardText.get -> string! +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.HeardText.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.PlayedDuration.get -> System.TimeSpan +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.PlayedDuration.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.ConversationInput(AgentCore.AspNetCore.Voice.Ports.ConversationInput! original) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Deconstruct(out string! Key) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress? other) -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Key.get -> string! +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Key.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Keypress(string! Key) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Deconstruct(out string! Text, out string! Language, out bool IsFinal) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance? other) -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.IsFinal.get -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.IsFinal.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Language.get -> string! +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Language.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Text.get -> string! +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Text.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Utterance(string! Text, string! Language, bool IsFinal) -> void +AgentCore.AspNetCore.Voice.Turns.ConversationSpeechPairing +AgentCore.AspNetCore.Voice.Ports.CallTraits +AgentCore.AspNetCore.Voice.Ports.CallTraits.None = 0 -> AgentCore.AspNetCore.Voice.Ports.CallTraits +AgentCore.AspNetCore.Voice.Ports.CallTraits.SpeaksForItself = 1 -> AgentCore.AspNetCore.Voice.Ports.CallTraits +AgentCore.AspNetCore.Voice.Ports.CallTraits.TakesTurnsItself = 2 -> AgentCore.AspNetCore.Voice.Ports.CallTraits +AgentCore.AspNetCore.Voice.Ports.IConversationAdapter +AgentCore.AspNetCore.Voice.Ports.IConversationAdapter.CarriesText.get -> bool +AgentCore.AspNetCore.Voice.Ports.IConversationAdapter.Traits.get -> AgentCore.AspNetCore.Voice.Ports.CallTraits +AgentCore.AspNetCore.Voice.Routing.IConversationChannelFactory +AgentCore.AspNetCore.Voice.Routing.IConversationChannelFactory.OpenAsync(AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.Ports.IConversationInputPort +AgentCore.AspNetCore.Voice.Ports.IConversationInputPort.ListenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort +AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort.BeginReply() -> void +AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort.CompleteAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort.SpeakAsync(string! fragment, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.Ports.IConversationOutputPort.StopAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.Ports.ISpeechAdapter +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Deconstruct(out string! ConversationId) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started? other) -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.ConversationId.get -> string! +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.ConversationId.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Started(string! ConversationId) -> void +abstract AgentCore.AspNetCore.Voice.Ports.ConversationInput.$() -> AgentCore.AspNetCore.Voice.Ports.ConversationInput! +override AgentCore.AspNetCore.Voice.Routing.ConversationChannel.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Routing.ConversationChannel.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Routing.ConversationChannel.ToString() -> string! +override AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.ToString() -> string! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.$() -> AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.ToString() -> string! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.$() -> AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.ToString() -> string! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.ToString() -> string! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.$() -> AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.ToString() -> string! +static AgentCore.AspNetCore.Voice.Routing.ConversationChannel.operator !=(AgentCore.AspNetCore.Voice.Routing.ConversationChannel? left, AgentCore.AspNetCore.Voice.Routing.ConversationChannel? right) -> bool +static AgentCore.AspNetCore.Voice.Routing.ConversationChannel.operator ==(AgentCore.AspNetCore.Voice.Routing.ConversationChannel? left, AgentCore.AspNetCore.Voice.Routing.ConversationChannel? right) -> bool +static AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.operator !=(AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext? left, AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext? right) -> bool +static AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext.operator ==(AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext? left, AgentCore.AspNetCore.Voice.Routing.ConversationChannelContext? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.operator !=(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.operator ==(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.operator !=(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.operator ==(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.operator !=(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.operator ==(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.operator !=(AgentCore.AspNetCore.Voice.Ports.ConversationInput? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.operator ==(AgentCore.AspNetCore.Voice.Ports.ConversationInput? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput? right) -> bool +static AgentCore.AspNetCore.Voice.Turns.ConversationSpeechPairing.Validate(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! conversation, AgentCore.Application.Configuration.Schema.SpeechProviderConfiguration! speech, AgentCore.AspNetCore.Voice.Ports.IConversationAdapter! selectedConversation) -> void +virtual AgentCore.AspNetCore.Voice.Ports.ConversationInput.EqualityContract.get -> System.Type! +virtual AgentCore.AspNetCore.Voice.Ports.ConversationInput.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput? other) -> bool +virtual AgentCore.AspNetCore.Voice.Ports.ConversationInput.PrintMembers(System.Text.StringBuilder! builder) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.$() -> AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started! +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.ToString() -> string! +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.operator !=(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.operator ==(AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started? left, AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started? right) -> bool +override sealed AgentCore.AspNetCore.Voice.Ports.ConversationInput.Barge.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput? other) -> bool +override sealed AgentCore.AspNetCore.Voice.Ports.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput? other) -> bool +override sealed AgentCore.AspNetCore.Voice.Ports.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput? other) -> bool +override sealed AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationInput? other) -> bool AgentCore.AspNetCore.Endpoints.AgentCoreEntries -AgentCore.AspNetCore.Endpoints.EntrySelectorEndpointConventionBuilderExtensions -AgentCore.AspNetCore.Endpoints.IEntryFeature -AgentCore.AspNetCore.Endpoints.IEntryFeature.Entry.get -> string! -AgentCore.AspNetCore.Endpoints.IEntrySelector -AgentCore.AspNetCore.Endpoints.IEntrySelector.SelectAsync(Microsoft.AspNetCore.Http.HttpContext! http, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask static AgentCore.AspNetCore.Endpoints.AgentCoreEntries.ResolveAsync(Microsoft.AspNetCore.Http.HttpContext! http, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static AgentCore.AspNetCore.Endpoints.EntrySelectorEndpointConventionBuilderExtensions.SelectEntry(this Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +AgentCore.AspNetCore.DependencyInjection.AgentCoreHookOptions +static AgentCore.AspNetCore.DependencyInjection.AgentCoreHookOptions.UseHooks(this AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! options, params AgentCore.Application.Hooks.AgentHook![]! hooks) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +static AgentCore.AspNetCore.DependencyInjection.AgentCoreHookOptions.UseHooks(this AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! options) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.CarriesText.get -> bool +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate! +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.Kind.get -> string! +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.OpenAiLiveConversationAdapter(System.Net.Http.IHttpMessageHandlerFactory! handlers, AgentCore.Application.Ports.ISecretResolverPort? secrets = null) -> void +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.Traits.get -> AgentCore.AspNetCore.Voice.Ports.CallTraits +const AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.HttpClientName = "openai-live" -> string! +const AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.OpenAiLiveKind = "openai-live" -> string! +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.From.get -> string? +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.From.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Headers.get -> System.Collections.Generic.IReadOnlyDictionary? +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Headers.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.To.get -> string? +AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.To.init -> void diff --git a/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs b/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs index 26993bc5..c2c4f5d9 100644 --- a/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs +++ b/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs @@ -2,6 +2,7 @@ using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Sessions { @@ -104,7 +105,7 @@ public override async ValueTask GetSessionAsync( ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(sessionStoreId); - // Unknown to store 0: opened under the id itself, not a random one, so this key resolves by + // Unknown to the conversation store: 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) diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/CommentaryPieces.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/CommentaryPieces.cs new file mode 100644 index 00000000..61147d5e --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/CommentaryPieces.cs @@ -0,0 +1,56 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// Splits a reply into session.commentary.append pieces. An append carries at most 500 tokens; + /// 1,500 characters stays under that at the worst English ratio, about 3 characters per token. + /// + internal static class CommentaryPieces + { + internal const int MaxChars = 1500; + + internal static IReadOnlyList Split(string text, int maxChars = MaxChars) + { + ArgumentNullException.ThrowIfNull(text); + + List pieces = []; + string rest = text.Trim(); + while (rest.Length > maxChars) + { + int cut = LastSentenceEnd(rest, maxChars); + if (cut <= 0) + { + cut = rest.LastIndexOf(' ', maxChars - 1); + } + + if (cut <= 0) + { + cut = maxChars; + } + + pieces.Add(rest[..cut].TrimEnd()); + rest = rest[cut..].TrimStart(); + } + + if (rest.Length > 0) + { + pieces.Add(rest); + } + + return pieces; + } + + // The index of the space after the last ".", "?" or "!" within the first maxChars + 1 characters. + private static int LastSentenceEnd(string text, int maxChars) + { + for (int index = Math.Min(maxChars, text.Length - 1); index > 0; index--) + { + if (text[index] is ' ' or '\n' && text[index - 1] is '.' or '?' or '!') + { + return index; + } + } + + return -1; + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs new file mode 100644 index 00000000..9066c67d --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs @@ -0,0 +1,127 @@ +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// How one GPT-Live call ends from this side: at once, or once GPT-Live acked the last piece of an answer and its + /// speech then went quiet. The timers fire off the read loop; the loop acts on . + /// + /// The host's clock. + /// The call, for the log. + /// Where a late ack is logged. + internal sealed class LiveCallEnd(TimeProvider time, string callId, ILogger logger) : IDisposable + { + private readonly TaskCompletionSource<(ConversationEndReason Reason, string Cause)> _endNow = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly Lock _gate = new(); + + private string? _endAfterAck; + + private ITimer? _ackTimer; + + private ITimer? _quietTimer; + + private bool _disposed; + + private int _finished; + + /// Gets the end asked for, once one is. + internal Task<(ConversationEndReason Reason, string Cause)> Requested => _endNow.Task; + + /// Gets whether the call finished: nothing goes out on its socket any more. + internal bool Finished => Volatile.Read(ref _finished) == 1; + + /// Disposes the timers; none fires after this. + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _ackTimer?.Dispose(); + _quietTimer?.Dispose(); + } + } + + /// Marks the call finished. + /// when it already was. + internal bool TryFinish() => Interlocked.Exchange(ref _finished, 1) == 0; + + internal void Now(ConversationEndReason reason, string cause) => _endNow.TrySetResult((reason, cause)); + + /// GPT-Live is still speaking: a quiet wait already running starts again. + internal void AgentSpoke() + { + lock (_gate) + { + _ = _quietTimer?.Change(OpenAiLiveCall.EndQuietWait, Timeout.InfiniteTimeSpan); + } + } + + /// GPT-Live acked one append; the ack of the last piece starts the quiet wait. + internal void Acked(string eventId) + { + lock (_gate) + { + if (eventId == _endAfterAck) + { + _endAfterAck = null; + _ackTimer?.Dispose(); + _ackTimer = null; + _quietTimer?.Dispose(); + _quietTimer = QuietTimer(); + } + } + } + + // Armed before the send, so an ack that comes back at once still finds it. A quiet wait already running is + // dropped: only the ack of this last piece may start the wait. + internal void AfterAck(string eventId) + { + lock (_gate) + { + _quietTimer?.Dispose(); + _quietTimer = null; + _endAfterAck = eventId; + _ackTimer?.Dispose(); + _ackTimer = time.CreateTimer(state => AckLate((string)state!), eventId, OpenAiLiveCall.AckWait, Timeout.InfiniteTimeSpan); + } + } + + /// + /// Hangs up once GPT-Live's speech has been quiet for , unless an + /// answer's own end already waits, or the call already ended or is ending. + /// + internal void WhenQuiet() + { + lock (_gate) + { + if (_disposed || Finished || _endAfterAck is not null || _quietTimer is not null || _endNow.Task.IsCompleted) + { + return; + } + + _quietTimer = QuietTimer(); + } + } + + private ITimer QuietTimer() => + time.CreateTimer(_ => Now(ConversationEndReason.AgentCompleted, OpenAiLiveCall.AgentEndedCause), null, OpenAiLiveCall.EndQuietWait, Timeout.InfiniteTimeSpan); + + private void AckLate(string eventId) + { + lock (_gate) + { + if (eventId != _endAfterAck || Finished) + { + return; + } + + _endAfterAck = null; + } + + OpenAiLiveLog.AckLate(logger, callId, OpenAiLiveCall.AckWait.TotalSeconds); + Now(ConversationEndReason.AgentCompleted, OpenAiLiveCall.AgentEndedCause); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs new file mode 100644 index 00000000..1e3680fc --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs @@ -0,0 +1,25 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// GPT-Live's own instructions: the entry's configured prompt, the fixed paragraph that keeps it from guessing + /// facts (a plain prompt guessed in 4 of 6 test calls, this paragraph delegated in 6 of 6) and from saying + /// goodbye on a call only the backend can hang up (a real SIP call: asked to end the call, GPT-Live said goodbye + /// itself and the line stayed up), and the host's brief, appended exactly as written. + /// + internal static class LiveInstructions + { + internal const string NeverGuess = + "You know no product facts yourself. For any question about products, specifications, orders," + + " parts, or policies, delegate to the backend and wait for its answer. Never guess a fact." + + " You cannot end the call yourself: when the caller wants to end or hang up the call, delegate that" + + " to the backend, and say goodbye with its answer."; + + internal static string Build(string configured, string? brief) + { + ArgumentNullException.ThrowIfNull(configured); + + string instructions = configured.TrimEnd() + "\n\n" + NeverGuess; + return brief is null ? instructions : instructions + "\n\n" + brief; + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveLine.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveLine.cs new file mode 100644 index 00000000..0e1df27d --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveLine.cs @@ -0,0 +1,11 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// One closed spoken line. + /// Who said it. + /// The words, trimmed. + /// The first delta's start, in milliseconds from the session's start. + /// The last delta's end, in milliseconds from the session's start. + internal sealed record LiveLine(Speaker Speaker, string Text, int StartMs, int EndMs); +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTranscriptLedger.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTranscriptLedger.cs new file mode 100644 index 00000000..9144bb00 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTranscriptLedger.cs @@ -0,0 +1,97 @@ +using System.Text; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// Collects transcript deltas into lines, and splits the lines since the last delegation into one ask. A delegation + /// closes the open line by arrival order, never by timestamp: offset_ms is the start of the last word, so a + /// timestamp split moves that word into the next ask (13 of 13 test calls split cleanly by arrival order). Not thread-safe: one read loop owns it. + /// + internal sealed class LiveTranscriptLedger + { + private readonly List _sinceDelegation = []; + + private readonly StringBuilder _line = new(); + + private Speaker? _speaker; + + private int _start; + + private int _end; + + private bool _delegated; + + internal IReadOnlyList Add(LiveEvent.Transcript delta) + { + ArgumentNullException.ThrowIfNull(delta); + + List closed = []; + if (_speaker is { } open && open != delta.Speaker) + { + Close(closed); + } + + if (_speaker is null) + { + _speaker = delta.Speaker; + _start = delta.StartMs; + } + + _ = _line.Append(delta.Delta); + _end = delta.EndMs; + return closed; + } + + /// Closes the open line and takes the ask of one delegation. + /// + /// The line closed now, the lines that ride ahead of the ask's words, and the words: empty when the caller said + /// nothing since the last delegation. + /// + internal (IReadOnlyList Closed, IReadOnlyList Before, string CallerWords) TakeForDelegation() + { + List closed = []; + Close(closed); + + List lines = [.. _delegated ? _sinceDelegation.SkipWhile(static line => line.Speaker == Speaker.Agent) : _sinceDelegation]; + _sinceDelegation.Clear(); + _delegated = true; + + int last = lines.FindLastIndex(static line => line.Speaker == Speaker.Caller); + if (last < 0) + { + return (closed, [], string.Empty); + } + + int first = last; + while (first > 0 && lines[first - 1].Speaker == Speaker.Caller) + { + first--; + } + + string words = string.Join(' ', lines[first..(last + 1)].Select(static line => line.Text)); + return (closed, lines[..first], words); + } + + internal IReadOnlyList Flush() + { + List closed = []; + Close(closed); + return closed; + } + + private void Close(List closed) + { + if (_speaker is { } speaker && _line.ToString().Trim() is { Length: > 0 } text) + { + LiveLine line = new(speaker, text, _start, _end); + closed.Add(line); + _sinceDelegation.Add(line); + } + + _speaker = null; + _ = _line.Clear(); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs new file mode 100644 index 00000000..d359e207 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -0,0 +1,372 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// One live GPT-Live call: reads the sideband, keeps the transcript ledger, and answers each delegation through the + /// call core. Talks to the engine only through . + /// + internal sealed class OpenAiLiveCall(PhoneCall call, ILiveSideband sideband, Func> hangUp, ILogger logger) : IDisposable + { + internal const string ShutdownCause = "shutdown"; + + internal const string SidebandClosedCause = "sideband_closed"; + + internal const string SidebandFailedCause = "sideband_failed"; + + internal const string AttachFailedCause = "attach_failed"; + + internal const string AgentEndedCause = "agent_ended"; + + internal const string ConversationLostCause = "conversation_lost"; + + internal const string NothingNew = "The caller said nothing new since the last answer."; + + /// How long after its send the last answer piece may go unacked before an engine-ended call hangs up anyway. + internal static readonly TimeSpan AckWait = TimeSpan.FromSeconds(5); + + /// + /// How long GPT-Live's speech must stay quiet after the ack before an engine-ended call hangs up. The ack marks + /// the start of speech (p1-a), and no probe log has a speech-done event. + /// + internal static readonly TimeSpan EndQuietWait = TimeSpan.FromSeconds(2); + + private readonly LiveTranscriptLedger _ledger = new(); + + private readonly SemaphoreSlim _sending = new(1, 1); + + private readonly List _answers = []; + + // Set off the loop (an answer, a timer) and acted on by the loop, which alone touches the ledger. + private readonly LiveCallEnd _end = new(call.Host.Time, call.CallId, logger); + + private readonly TaskCompletionSource _loopLeft = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private int _looping; + + private int _hungUp; + + private long _eventIds; + + internal string CallId => call.CallId; + + internal ILogger Logger => logger; + + /// Disposes the send lock and the end timers. calls this once every answer is done. + public void Dispose() + { + _sending.Dispose(); + _end.Dispose(); + } + + internal async Task RunAsync(CancellationToken cancellationToken) + { + Task? receiving = null; + Volatile.Write(ref _looping, 1); + _ = HangUpAfterAnyEngineEndAsync(); + try + { + // GPT-Live knows no date of its own: on a real call it named the wrong weekday. + if (call.Session.Runner.ClockLine() is { } clock) + { + await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false); + } + + await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId()), cancellationToken).ConfigureAwait(false); + while (true) + { + receiving ??= sideband.ReceiveAsync(cancellationToken).AsTask(); + _ = await Task.WhenAny(receiving, _end.Requested).ConfigureAwait(false); + if (_end.Requested.IsCompleted) + { + (ConversationEndReason reason, string cause) = await _end.Requested.ConfigureAwait(false); + await HearReceivedAsync(receiving).ConfigureAwait(false); + await HearAsync(_ledger.Flush()).ConfigureAwait(false); + OpenAiLiveLog.HungUpBecause(logger, CallId, cause); + await HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); + return; + } + + string? json = await receiving.ConfigureAwait(false); + receiving = null; + if (json is null) + { + break; + } + + if (await HandleAsync(LiveEventReader.Read(json), cancellationToken).ConfigureAwait(false)) + { + return; + } + } + + await HearAsync(_ledger.Flush()).ConfigureAwait(false); + await FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The host is stopping; OpenAiLiveCalls ends the call itself. + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + OpenAiLiveLog.SidebandFaulted(logger, CallId, fault); + await HearAsync(_ledger.Flush()).ConfigureAwait(false); + await FinishAsync(ConversationEndReason.Faulted, SidebandFailedCause).ConfigureAwait(false); + } + finally + { + _ = _loopLeft.TrySetResult(); + await WhenAnsweredAsync().ConfigureAwait(false); + Dispose(); + await sideband.DisposeAsync().ConfigureAwait(false); + + // A receive left behind by an end ends with the socket; nothing reads its result. + _ = receiving?.ContinueWith(static left => left.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + } + + /// + /// Hangs the call up from this side, then ends it, for host shutdown. A running loop ends the call + /// itself, as for any other end, so its open line is heard first; a loop still short of that end when + /// fires is cut off, and the call is ended from here. + /// + internal async Task EndAsync(ConversationEndReason reason, string cause, CancellationToken cancellationToken) + { + _end.Now(reason, cause); + if (Volatile.Read(ref _looping) == 1) + { + try + { + await _loopLeft.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The host stopped waiting: end the call anyway. + } + } + + await HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken cancellationToken) + { + switch (liveEvent) + { + case LiveEvent.Transcript delta: + if (delta.Speaker == Speaker.Agent) + { + _end.AgentSpoke(); + } + + await HearAsync(_ledger.Add(delta)).ConfigureAwait(false); + return false; + + case LiveEvent.Delegation delegation: + (IReadOnlyList closed, IReadOnlyList before, string words) = _ledger.TakeForDelegation(); + await HearAsync(closed).ConfigureAwait(false); + lock (_answers) + { + _answers.Add(AnswerAsync(delegation.Id, before, words, cancellationToken)); + } + + return false; + + case LiveEvent.Appended { EventId: { } eventId }: + _end.Acked(eventId); + return false; + + case LiveEvent.Closed end: + await HearAsync(_ledger.Flush()).ConfigureAwait(false); + await FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); + return true; + + case LiveEvent.Failed failed: + OpenAiLiveLog.LiveError(logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty); + return false; + + default: + return false; + } + } + + // AskAsync runs before this method's first await: asks take their place in arrival order. The call + // never ends from inside the ask (EndAsync would wait up to CallAsks.OlderAskWait on the ask itself), and never + // from this task: the loop ends it, after its ledger flush. + private async Task AnswerAsync(string delegationId, IReadOnlyList before, string words, CancellationToken cancellationToken) + { + try + { + if (words.Length == 0) + { + await SendAsync(OpenAiLiveEvents.Thinking(delegationId, NothingNew, NextEventId()), cancellationToken).ConfigureAwait(false); + if (call.EngineEnded) + { + EndNow(ConversationEndReason.AgentCompleted, AgentEndedCause); + } + + return; + } + + CallAnswer answer = await call.Asks.AskAsync( + delegationId, + words, + [.. before.Select(line => line.Speaker == Speaker.Caller ? new ChatMessage(ChatRole.User, line.Text) : FrontVoice.Line(line.Text))], + tool => SendAsync(OpenAiLiveEvents.Thinking(delegationId, $"The backend is running the tool {tool}. No answer yet.", NextEventId()), cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (answer.Kind == CallAnswerKind.Withdrawn) + { + return; + } + + await SpeakAsync(delegationId, answer.Text, endAfter: call.EngineEnded, cancellationToken).ConfigureAwait(false); + } + catch (CallConversationLostException) + { + EndNow(ConversationEndReason.Faulted, ConversationLostCause); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The host is stopping. + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + OpenAiLiveLog.AnswerFaulted(logger, CallId, fault); + } + } + + // Once the engine ended the conversation, the call hangs up after GPT-Live acked the last piece and + // then went quiet for EndQuietWait, or AckWait after that piece's send with no ack. + private async Task SpeakAsync(string delegationId, string text, bool endAfter, CancellationToken cancellationToken) + { + IReadOnlyList pieces = CommentaryPieces.Split(text); + if (endAfter && pieces.Count == 0) + { + EndNow(ConversationEndReason.AgentCompleted, AgentEndedCause); + return; + } + + for (int index = 0; index < pieces.Count; index++) + { + string eventId = NextEventId(); + if (endAfter && index == pieces.Count - 1) + { + _end.AfterAck(eventId); + } + + await SendAsync(OpenAiLiveEvents.Commentary(delegationId, pieces[index], eventId), cancellationToken).ConfigureAwait(false); + } + } + + // An end from outside every delegation (an operator's, the host's) has no answer of its own to hang up after. + // Once the delegations in flight are answered, a call none of them set to end hangs up when GPT-Live's speech + // has been quiet for EndQuietWait. The session is read once: an unload during the call is not followed. + private async Task HangUpAfterAnyEngineEndAsync() + { + await call.Session.Lifetime.Ending.WhenRequested.ConfigureAwait(false); + await WhenAnsweredAsync().ConfigureAwait(false); + _end.WhenQuiet(); + } + + private void EndNow(ConversationEndReason reason, string cause) => _end.Now(reason, cause); + + // A message that arrived together with the end is still part of the call: its words are heard. A delegation in + // it is not answered, and the ledger flush after this closes the line it would have closed. + private ValueTask HearReceivedAsync(Task? receiving) => + receiving is { IsCompletedSuccessfully: true, Result: { } json } && LiveEventReader.Read(json) is LiveEvent.Transcript delta + ? HearAsync(_ledger.Add(delta)) + : ValueTask.CompletedTask; + + private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancellationToken) + { + await _sending.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Nothing goes out once the call ended: the socket may already be gone. + if (!_end.Finished) + { + await sideband.SendAsync(liveEvent.ToJsonString(), cancellationToken).ConfigureAwait(false); + } + } + finally + { + _ = _sending.Release(); + } + } + + private async ValueTask HearAsync(IReadOnlyList lines) + { + foreach (LiveLine line in lines) + { + await call.HeardAsync( + line.Speaker, + line.Text, + call.StartedAt.AddMilliseconds(line.StartMs), + call.StartedAt.AddMilliseconds(line.EndMs), + call.Asks.AnsweredTurn).ConfigureAwait(false); + } + } + + private async Task HangUpAndFinishAsync(ConversationEndReason reason, string cause, CancellationToken cancellationToken) + { + if (!_end.Finished && Interlocked.Exchange(ref _hungUp, 1) == 0) + { + bool hungUp; + try + { + hungUp = await hangUp(cancellationToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.HangupFaulted(logger, CallId, fault); + hungUp = true; + } + + if (!hungUp) + { + OpenAiLiveLog.HangupFailed(logger, CallId); + } + } + + await FinishAsync(reason, cause).ConfigureAwait(false); + } + + private async Task FinishAsync(ConversationEndReason reason, string cause) + { + if (!_end.TryFinish()) + { + return; + } + + await call.EndAsync(reason, cause).ConfigureAwait(false); + await WhenAnsweredAsync().ConfigureAwait(false); + try + { + await call.CloseAsync().ConfigureAwait(false); + } + // The close takes no token, so a cancellation here is the store's own timeout. + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.CloseFaulted(logger, CallId, fault); + } + } + + private Task WhenAnsweredAsync() + { + lock (_answers) + { + return Task.WhenAll(_answers); + } + } + + private string NextEventId() => "ac" + Interlocked.Increment(ref _eventIds).ToString(CultureInfo.InvariantCulture); + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs new file mode 100644 index 00000000..da80c352 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs @@ -0,0 +1,164 @@ +using System.Collections.Concurrent; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive +{ + /// + /// Owns every live GPT-Live call of the process, because a call outlives the webhook request. It also + /// remembers webhook ids and claims call ids, so one process never admits one call twice, checks the + /// adapter's keys at start, and hangs up and ends each live call at shutdown. + /// + internal sealed class OpenAiLiveCalls(IServiceProvider services) : IHostedService, IDisposable + { + /// How long a webhook id is remembered. OpenAI retries a webhook within minutes. + internal static readonly TimeSpan WebhookMemory = TimeSpan.FromMinutes(10); + + private readonly ConcurrentDictionary _webhooks = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary _claimed = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary _running = new(); + + private readonly CancellationTokenSource _stopping = new(); + + private readonly Lock _gate = new(); + + private bool _stopped; + + /// Gets the token of the host's stop; a disposed registry reads as stopped. + internal CancellationToken Stopping + { + get + { + try + { + return _stopping.Token; + } + catch (ObjectDisposedException) + { + return new CancellationToken(canceled: true); + } + } + } + + internal bool TryClaimWebhook(string webhookId, DateTimeOffset now) + { + foreach (KeyValuePair seen in _webhooks) + { + if (now - seen.Value > WebhookMemory) + { + _ = _webhooks.TryRemove(seen); + } + } + + return _webhooks.TryAdd(webhookId, now); + } + + internal bool TryClaimCall(string callId) => _claimed.TryAdd(callId, 0); + + internal void ReleaseCall(string callId) => _claimed.TryRemove(callId, out _); + + /// Forgets a webhook id whose request failed, so the retry OpenAI sends under the same id is processed. + internal void ReleaseWebhook(string webhookId) => _webhooks.TryRemove(webhookId, out _); + + /// + /// Runs a call to its end. A call that arrives after the host began to stop is hung up and ended with cause + /// instead: no call outlives the host. + /// + internal void Run(OpenAiLiveCall call) + { + TaskCompletionSource done = new(TaskCreationOptions.RunContinuationsAsynchronously); + bool late; + lock (_gate) + { + late = _stopped; + _running[call] = done.Task; + } + + _ = RunAsync(call, late, done); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + AgentCoreBoot boot = services.GetRequiredService(); + foreach (OpenAiLiveConversationAdapter adapter in boot.ConversationAdapters?.OfType() ?? []) + { + if (adapter.Credentials is { } pending) + { + _ = await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + OpenAiLiveCall[] live; + lock (_gate) + { + _stopped = true; + live = [.. _running.Keys]; + } + + try + { + await Task.WhenAll(live.Select(call => call.EndAsync(ConversationEndReason.Faulted, OpenAiLiveCall.ShutdownCause, cancellationToken))) + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + finally + { + await _stopping.CancelAsync().ConfigureAwait(false); + + // A call that registered after the snapshot above ends itself as late; wait for it too. + while (!_running.IsEmpty) + { + await Task.WhenAll(_running.Values).WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + /// + public void Dispose() + { + _stopping.Dispose(); + } + + private async Task RunAsync(OpenAiLiveCall call, bool late, TaskCompletionSource done) + { + await Task.Yield(); + try + { + if (late) + { + try + { + await call.EndAsync(ConversationEndReason.Faulted, OpenAiLiveCall.ShutdownCause, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.RunFaulted(call.Logger, call.CallId, fault); + } + } + + // With the token already cancelled, RunAsync only waits for the answers and disposes the call and its sideband. + await call.RunAsync(late ? new CancellationToken(canceled: true) : Stopping).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.RunFaulted(call.Logger, call.CallId, fault); + } + finally + { + _ = _running.TryRemove(call, out _); + ReleaseCall(call.CallId); + _ = done.TrySetResult(); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs new file mode 100644 index 00000000..7b79db15 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs @@ -0,0 +1,100 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.AspNetCore.Voice.Ports; +using Microsoft.AspNetCore.Http; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive +{ + /// + /// OpenAI GPT-Live over a SIP trunk as a conversation transport. GPT-Live speaks and takes + /// turns itself; AgentCore answers its client delegations over the sideband, as text. + /// + public sealed class OpenAiLiveConversationAdapter : IConversationTransportAdapter + { + /// The one providers.conversation.kind value this vendor answers to. + public const string OpenAiLiveKind = "openai-live"; + + /// The name this adapter asks the outbound pipeline for. + public const string HttpClientName = "openai-live"; + + private readonly IHttpMessageHandlerFactory _handlers; + + private readonly Func _secrets; + + private readonly Func> _attach; + + private readonly Uri _apiBase; + + /// Creates the adapter over the host's outbound pipeline. + /// The pipeline that holds the connection lifetime, the deadline, and the retry. + /// The chain the two keys resolve through, or for the environment alone. + public OpenAiLiveConversationAdapter(IHttpMessageHandlerFactory handlers, ISecretResolverPort? secrets = null) + : this(handlers, () => secrets, WebSocketSideband.ConnectAsync, OpenAiLiveWire.ApiBase) + { + } + + /// Creates the adapter over a resolver read when the handler is built, not now. + /// The pipeline that holds the connection lifetime, the deadline, and the retry. + /// Reads the chain the two keys resolve through, once the host has finished configuring. + internal OpenAiLiveConversationAdapter(IHttpMessageHandlerFactory handlers, Func secrets) + : this(handlers, secrets, WebSocketSideband.ConnectAsync, OpenAiLiveWire.ApiBase) + { + } + + internal OpenAiLiveConversationAdapter( + IHttpMessageHandlerFactory handlers, + ISecretResolverPort? secrets, + Func> attach, + Uri apiBase) + : this(handlers, () => secrets, attach, apiBase) + { + } + + private OpenAiLiveConversationAdapter( + IHttpMessageHandlerFactory handlers, + Func secrets, + Func> attach, + Uri apiBase) + { + ArgumentNullException.ThrowIfNull(handlers); + ArgumentNullException.ThrowIfNull(secrets); + ArgumentNullException.ThrowIfNull(attach); + ArgumentNullException.ThrowIfNull(apiBase); + + _handlers = handlers; + _secrets = secrets; + _attach = attach; + _apiBase = apiBase; + } + + /// + public string Kind => OpenAiLiveKind; + + /// + public bool CarriesText => true; + + /// + public CallTraits Traits => CallTraits.SpeaksForItself | CallTraits.TakesTurnsItself; + + /// Gets the keys being resolved once ran, or . + internal Task? Credentials { get; private set; } + + /// + /// The live: block is missing or wrong. + public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + OpenAiLiveSettings settings = OpenAiLiveSettings.From(configuration); + Task credentials = OpenAiLiveCredentials.ResolveAsync(_secrets()); + Credentials = credentials; + + // The pipeline owns the handler chain, and other clients send on it, so this client disposes nothing. + HttpClient http = new(_handlers.CreateHandler(HttpClientName), disposeHandler: false) { BaseAddress = _apiBase }; + OpenAiLiveWebhook webhook = new(settings, configuration, credentials, new OpenAiLiveControl(http, credentials), _attach, _apiBase); + return webhook.HandleAsync; + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCredentials.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCredentials.cs new file mode 100644 index 00000000..fa157060 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCredentials.cs @@ -0,0 +1,32 @@ +using AgentCore.Application.Ports; +using AgentCore.Application.Secrets; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive +{ + /// The OpenAI key (accept, attach, hang up) and the webhook signing key. + /// The OpenAI API key. + /// The decoded webhook signing key. + internal sealed record OpenAiLiveCredentials(string ApiKey, byte[] WebhookKey) + { + /// Neither the chain nor the environment holds one of the two, or the webhook secret is not a valid signing secret. + internal static async Task ResolveAsync(ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + string apiKey = await secrets.RequireAsync( + KnownSecrets.OpenAi, "The openai-live vendor accepts, attaches to, and hangs up each call with it.", cancellationToken).ConfigureAwait(false); + string webhook = await secrets.RequireAsync( + KnownSecrets.OpenAiWebhook, "The openai-live vendor checks the signature of every incoming-call webhook with it.", cancellationToken).ConfigureAwait(false); + try + { + return new OpenAiLiveCredentials(apiKey, StandardWebhookSignature.KeyOf(webhook)); + } + catch (FormatException failure) + { + throw new SecretResolutionException( + $"The secret '{KnownSecrets.OpenAiWebhookSecretName}' (variable {KnownSecrets.OpenAiWebhookSecretVariable}) is not a Standard Webhooks signing secret: " + + "it must be 'whsec_' followed by a base64 key.", + failure); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs new file mode 100644 index 00000000..74fb2378 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs @@ -0,0 +1,48 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive +{ + /// The openai-live adapter's log lines. None carries what anyone said, nor a key. + internal static partial class OpenAiLiveLog + { + [LoggerMessage(EventId = 310, Level = LogLevel.Warning, Message = "an openai-live webhook failed its signature check and was refused.")] + internal static partial void SignatureRefused(ILogger logger); + + [LoggerMessage(EventId = 311, Level = LogLevel.Warning, Message = "OpenAI did not take the accept of call {CallId}: {Outcome}.")] + internal static partial void AcceptRefused(ILogger logger, string callId, AcceptOutcome outcome); + + [LoggerMessage(EventId = 312, Level = LogLevel.Error, Message = "attaching to the call {CallId} failed, so it was hung up.")] + internal static partial void AttachFailed(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 313, Level = LogLevel.Error, Message = "the sideband of the call {CallId} failed.")] + internal static partial void SidebandFaulted(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 314, Level = LogLevel.Warning, Message = "GPT-Live reported an error on the call {CallId}: {Code}: {Detail}")] + internal static partial void LiveError(ILogger logger, string callId, string code, string detail); + + [LoggerMessage(EventId = 315, Level = LogLevel.Error, Message = "answering a delegation of the call {CallId} failed.")] + internal static partial void AnswerFaulted(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 316, Level = LogLevel.Warning, Message = "hanging up the call {CallId} failed.")] + internal static partial void HangupFailed(ILogger logger, string callId); + + [LoggerMessage(EventId = 317, Level = LogLevel.Warning, Message = "rejecting the call {CallId} failed.")] + internal static partial void RejectFailed(ILogger logger, string callId); + + [LoggerMessage(EventId = 318, Level = LogLevel.Error, Message = "closing the session of the call {CallId} failed.")] + internal static partial void CloseFaulted(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 319, Level = LogLevel.Warning, Message = "GPT-Live did not ack the last answer of the call {CallId} within {Seconds} s, so the call is hung up anyway.")] + internal static partial void AckLate(ILogger logger, string callId, double seconds); + + [LoggerMessage(EventId = 320, Level = LogLevel.Information, Message = "the call {CallId} is hung up from this side: {Cause}.")] + internal static partial void HungUpBecause(ILogger logger, string callId, string cause); + + [LoggerMessage(EventId = 321, Level = LogLevel.Error, Message = "hanging up the call {CallId} threw, so it is ended anyway.")] + internal static partial void HangupFaulted(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 322, Level = LogLevel.Error, Message = "running the call {CallId} failed.")] + internal static partial void RunFaulted(ILogger logger, string callId, Exception fault); + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs new file mode 100644 index 00000000..016787ff --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Secrets; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive +{ + /// The providers.conversation.live block, read and checked at boot. + /// GPT-Live's own prompt: tone and greeting. + /// The voice GPT-Live speaks with. + /// The GPT-Live model. + internal sealed record OpenAiLiveSettings(string Instructions, string Voice, string Model) + { + internal const string DefaultVoice = "marin"; + + internal const string DefaultModel = "gpt-live-1"; + + private const string Pointer = "/providers/conversation/live"; + + /// The block is missing, lacks instructions, or names a key it does not hold. + internal static OpenAiLiveSettings From(ConversationProviderConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + if (configuration.Live.ValueKind != JsonValueKind.Object) + { + throw Fail(string.Empty, "providers.conversation.live is missing. The openai-live vendor needs live.instructions: GPT-Live's own prompt."); + } + + string? instructions = null; + string? voice = null; + string? model = null; + foreach (JsonProperty property in configuration.Live.EnumerateObject()) + { + switch (property.Name) + { + case "instructions": + instructions = Text(property); + break; + case "voice": + voice = Text(property); + break; + case "model": + model = Text(property); + break; + default: + throw Fail( + "/" + property.Name, + $"providers.conversation.live.{property.Name} is not a setting of the openai-live vendor. It reads instructions, voice, and model. " + + $"The OpenAI key and the webhook secret come from the secret resolver as '{KnownSecrets.OpenAiApiKeyName}' and '{KnownSecrets.OpenAiWebhookSecretName}'."); + } + } + + return string.IsNullOrWhiteSpace(instructions) + ? throw Fail("/instructions", "providers.conversation.live.instructions is missing or empty. GPT-Live needs its own prompt.") + : new OpenAiLiveSettings(instructions, voice ?? DefaultVoice, model ?? DefaultModel); + } + + private static string Text(JsonProperty property) + { + return property.Value.ValueKind == JsonValueKind.String && property.Value.GetString() is { Length: > 0 } text + ? text + : throw Fail("/" + property.Name, $"providers.conversation.live.{property.Name} must be a non-empty string."); + } + + private static ConfigurationLoadException Fail(string suffix, string message) + { + return new(new ConfigurationError + { + Pointer = Pointer + suffix, + Message = message, + Check = ConfigurationCheck.ReferenceResolution, + }); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/AcceptOutcome.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/AcceptOutcome.cs new file mode 100644 index 00000000..c0e0e719 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/AcceptOutcome.cs @@ -0,0 +1,14 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook +{ + /// How OpenAI answered an accept. + internal enum AcceptOutcome + { + Accepted, + + /// Another accept or reject of the call came first (). + AlreadyDecided, + + Failed, + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/LiveIncomingCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/LiveIncomingCall.cs new file mode 100644 index 00000000..a5b07a55 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/LiveIncomingCall.cs @@ -0,0 +1,10 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook +{ + /// One incoming-call webhook, as reads it. + /// The id every call URL names. + /// The caller's number from the SIP From header, or . + /// The called number from the SIP To header, or . + /// Every SIP header, by name, case-insensitive; repeated names joined with ", ". + internal sealed record LiveIncomingCall(string CallId, string? From, string? To, IReadOnlyDictionary Headers); +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs new file mode 100644 index 00000000..fcbf3f3b --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -0,0 +1,207 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.AspNetCore.Voice.Routing; +using AgentCore.Domain.Audit; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook +{ + /// The /v1/{entry}/call handler for GPT-Live's incoming-call webhook. + internal sealed class OpenAiLiveWebhook( + OpenAiLiveSettings settings, + ConversationProviderConfiguration configuration, + Task credentials, + OpenAiLiveControl control, + Func> attach, + Uri apiBase) + { + /// The largest webhook body read. An incoming-call event is about one kilobyte. + internal const int MaxBodyBytes = 64 * 1024; + + internal async Task HandleAsync(HttpContext http) + { + if (!HttpMethods.IsPost(http.Request.Method)) + { + http.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + IServiceProvider services = http.RequestServices; + ILogger logger = services.GetRequiredService().CreateLogger("AgentCore.OpenAiLive"); + TimeProvider time = services.GetRequiredService(); + OpenAiLiveCalls calls = services.GetRequiredService(); + OpenAiLiveCredentials keys = await credentials.ConfigureAwait(false); + + if (await ReadBodyAsync(http.Request, http.RequestAborted).ConfigureAwait(false) is not { } body) + { + http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + + string? webhookId = Header(http, "webhook-id"); + if (!StandardWebhookSignature.Verify(keys.WebhookKey, webhookId, Header(http, "webhook-timestamp"), body, Header(http, "webhook-signature"), time.GetUtcNow())) + { + OpenAiLiveLog.SignatureRefused(logger); + http.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + http.Response.StatusCode = StatusCodes.Status200OK; + if (!calls.TryClaimWebhook(webhookId!, time.GetUtcNow()) + || OpenAiLiveWire.ReadIncomingCall(body) is not { } incoming + || !calls.TryClaimCall(incoming.CallId)) + { + return; + } + + bool handed = false; + try + { + handed = await AnswerAsync(http, incoming, keys, calls, logger).ConfigureAwait(false); + } + catch (Exception) + { + // The request ends in a 500, and OpenAI redelivers under the same webhook id. + calls.ReleaseWebhook(webhookId!); + throw; + } + finally + { + if (!handed) + { + calls.ReleaseCall(incoming.CallId); + } + } + } + + private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming, OpenAiLiveCredentials keys, OpenAiLiveCalls calls, ILogger logger) + { + CancellationToken stopping = calls.Stopping; + PhoneCallHost host = PhoneCallHost.From(http.RequestServices, configuration) with { FrontVoice = true }; + CallOffer offer = new( + ConversationEndpointRouteBuilderExtensions.EntryOf(http), incoming.CallId, incoming.From, incoming.To, incoming.Headers, + OpenAiLiveConversationAdapter.OpenAiLiveKind); + + PhoneCallAdmission admission = await PhoneCall.AdmitAsync(host, offer, stopping).ConfigureAwait(false); + if (admission.Call is not { } call) + { + if (!await control.RejectAsync(incoming.CallId, admission.Refusal ?? CallRefusal.Unavailable, stopping).ConfigureAwait(false)) + { + OpenAiLiveLog.RejectFailed(logger, incoming.CallId); + } + + return false; + } + + if (!await AcceptAndStartAsync(call, incoming.CallId, logger, stopping).ConfigureAwait(false)) + { + return false; + } + + ILiveSideband sideband; + try + { + sideband = await attach(new LiveAttach(incoming.CallId, keys.ApiKey, apiBase), stopping).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.AttachFailed(logger, incoming.CallId, fault); + await HangUpAsync(incoming.CallId, logger).ConfigureAwait(false); + await call.EndAsync(ConversationEndReason.Faulted, OpenAiLiveCall.AttachFailedCause).ConfigureAwait(false); + await call.CloseAsync().ConfigureAwait(false); + return false; + } + + calls.Run(new OpenAiLiveCall(call, sideband, _ => control.HangupQuietlyAsync(incoming.CallId), logger)); + return true; + } + + // Every admitted call reaches StartAsync or AbandonAsync, whatever the accept does: a call that never starts + // would keep its conversation busy. + private async Task AcceptAndStartAsync(PhoneCall call, string callId, ILogger logger, CancellationToken stopping) + { + bool started = false; + try + { + if (!await AcceptedAsync(call, callId, logger, stopping).ConfigureAwait(false)) + { + return false; + } + + await call.StartAsync(stopping).ConfigureAwait(false); + started = true; + return true; + } + catch (Exception) + { + // A thrown accept may still have landed, and a failed start follows an accept that did: either way + // GPT-Live would be left with a call and no sideband. + await HangUpAsync(callId, logger).ConfigureAwait(false); + throw; + } + finally + { + if (!started) + { + await call.AbandonAsync().ConfigureAwait(false); + } + } + } + + private async Task AcceptedAsync(PhoneCall call, string callId, ILogger logger, CancellationToken stopping) + { + AcceptOutcome outcome = await control.AcceptAsync( + callId, + OpenAiLiveWire.AcceptBody(settings.Model, settings.Voice, LiveInstructions.Build(settings.Instructions, call.Brief)), + stopping).ConfigureAwait(false); + if (outcome == AcceptOutcome.Accepted) + { + return true; + } + + OpenAiLiveLog.AcceptRefused(logger, callId, outcome); + if (outcome == AcceptOutcome.Failed && !await control.RejectQuietlyAsync(callId, CallRefusal.Unavailable).ConfigureAwait(false)) + { + OpenAiLiveLog.RejectFailed(logger, callId); + } + + return false; + } + + private async Task HangUpAsync(string callId, ILogger logger) + { + if (!await control.HangupQuietlyAsync(callId).ConfigureAwait(false)) + { + OpenAiLiveLog.HangupFailed(logger, callId); + } + } + + private static string? Header(HttpContext http, string name) => + http.Request.Headers.TryGetValue(name, out StringValues value) ? value.ToString() : null; + + private static async Task ReadBodyAsync(HttpRequest request, CancellationToken cancellationToken) + { + using MemoryStream body = new(); + byte[] buffer = new byte[8192]; + int read; + while ((read = await request.Body.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + if (body.Length + read > MaxBodyBytes) + { + return null; + } + + body.Write(buffer, 0, read); + } + + return body.ToArray(); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/StandardWebhookSignature.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/StandardWebhookSignature.cs new file mode 100644 index 00000000..90b41b0b --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/StandardWebhookSignature.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook +{ + /// + /// The Standard Webhooks signature check OpenAI webhooks use: HMAC-SHA256 over "{id}.{timestamp}.{body}", sent as + /// one or more space-separated "v1,{base64}" values (standardwebhooks.com). + /// + internal static class StandardWebhookSignature + { + /// How far the timestamp may be from now, either way, before a replay is assumed. + internal static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5); + + private const string SecretPrefix = "whsec_"; + + private const string VersionPrefix = "v1,"; + + /// The secret is not base64 after its prefix, or holds no key. + internal static byte[] KeyOf(string secret) + { + ArgumentException.ThrowIfNullOrEmpty(secret); + byte[] key = Convert.FromBase64String(secret.StartsWith(SecretPrefix, StringComparison.Ordinal) ? secret[SecretPrefix.Length..] : secret); + return key.Length > 0 ? key : throw new FormatException("The webhook secret holds no key after its prefix."); + } + + internal static bool Verify(byte[] key, string? id, string? timestamp, byte[] body, string? signatures, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(body); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(signatures) + || !long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out long seconds) + || seconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds() + || (now - DateTimeOffset.FromUnixTimeSeconds(seconds)).Duration() > Tolerance) + { + return false; + } + + byte[] signed = [.. Encoding.UTF8.GetBytes($"{id}.{timestamp}."), .. body]; + byte[] expected = HMACSHA256.HashData(key, signed); + foreach (string signature in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (signature.StartsWith(VersionPrefix, StringComparison.Ordinal) + && TryBase64(signature[VersionPrefix.Length..]) is { } given + && CryptographicOperations.FixedTimeEquals(given, expected)) + { + return true; + } + } + + return false; + } + + private static byte[]? TryBase64(string text) + { + byte[] buffer = new byte[text.Length]; + return Convert.TryFromBase64String(text, buffer, out int written) ? buffer[..written] : null; + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/ILiveSideband.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/ILiveSideband.cs new file mode 100644 index 00000000..4373e444 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/ILiveSideband.cs @@ -0,0 +1,11 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// The text channel to one live GPT-Live call: one JSON event per message, both ways. + internal interface ILiveSideband : IAsyncDisposable + { + /// The next whole message, or once the socket closed. + ValueTask ReceiveAsync(CancellationToken cancellationToken); + + ValueTask SendAsync(string json, CancellationToken cancellationToken); + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveAttach.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveAttach.cs new file mode 100644 index 00000000..63dfca6d --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveAttach.cs @@ -0,0 +1,8 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// What attaching to one accepted call needs. + /// The call. + /// The OpenAI key, sent as a bearer token. + /// The API's base address; the attach URL is under it. + internal sealed record LiveAttach(string CallId, string ApiKey, Uri ApiBase); +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs new file mode 100644 index 00000000..ca4b7acf --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs @@ -0,0 +1,33 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// One sideband event this adapter acts on. + internal abstract record LiveEvent + { + private protected LiveEvent() + { + } + + /// A piece of what one side said. Times are milliseconds from the session's start; start_ms of the last word, not its end. + internal sealed record Transcript(Speaker Speaker, string Delta, int StartMs, int EndMs) : LiveEvent; + + /// GPT-Live asks the backend for help. It carries no task text. + internal sealed record Delegation(string Id, int? OffsetMs) : LiveEvent; + + /// + /// GPT-Live took one session.commentary.append. is the event_id that + /// append carried, which the ack echoes as client_event_id. + /// + internal sealed record Appended(string? EventId) : LiveEvent; + + /// The session ended. + internal sealed record Closed(string? Reason) : LiveEvent; + + /// GPT-Live reported an error. + internal sealed record Failed(string? Code, string? Message) : LiveEvent; + + /// An event this adapter does not act on, or a known one with a field missing. + internal sealed record Other(string Type) : LiveEvent; + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs new file mode 100644 index 00000000..5da5b240 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// Reads one sideband message. It never throws: a message it cannot use becomes . + internal static class LiveEventReader + { + internal static LiveEvent Read(string json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException) + { + return new LiveEvent.Other("unparseable"); + } + + using JsonDocument parsed = document; + JsonElement root = parsed.RootElement; + string type = Text(root, "type") ?? string.Empty; + + return type switch + { + OpenAiLiveEvents.InputTranscriptDelta => Transcript(root, Speaker.Caller, type), + OpenAiLiveEvents.OutputTranscriptDelta => Transcript(root, Speaker.Agent, type), + OpenAiLiveEvents.DelegationCreated => root.TryGetProperty("delegation", out JsonElement delegation) && Text(delegation, "id") is { Length: > 0 } id + ? new LiveEvent.Delegation(id, Number(root, "offset_ms")) + : new LiveEvent.Other(type), + OpenAiLiveEvents.CommentaryAppended => new LiveEvent.Appended(Text(root, "client_event_id")), + OpenAiLiveEvents.Closed => new LiveEvent.Closed(Text(root, "reason")), + OpenAiLiveWire.ErrorEvent => root.TryGetProperty("error", out JsonElement error) && error.ValueKind == JsonValueKind.Object + ? new LiveEvent.Failed(Text(error, "code"), Text(error, "message")) + : new LiveEvent.Failed(Code: null, Message: null), + _ => new LiveEvent.Other(type), + }; + } + + private static LiveEvent Transcript(JsonElement root, Speaker speaker, string type) + { + return Text(root, "delta") is { } delta && Number(root, "start_ms") is { } start && Number(root, "end_ms") is { } end + ? new LiveEvent.Transcript(speaker, delta, start, end) + : new LiveEvent.Other(type); + } + + private static string? Text(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static int? Number(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) + && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double number) + && number is >= int.MinValue and <= int.MaxValue + ? (int)number + : null; + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs new file mode 100644 index 00000000..71ae13a6 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Gates; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// OpenAI's call-control requests for a SIP call: accept, reject, hang up. URLs and bodies come from . + internal sealed class OpenAiLiveControl(HttpClient http, Task credentials) + { + /// The deadline of a best-effort request that must not wait on the host's token: a hang-up at shutdown, a reject after a failed accept. + internal static readonly TimeSpan QuietDeadline = TimeSpan.FromSeconds(5); + + internal async Task AcceptAsync(string callId, JsonObject body, CancellationToken cancellationToken) + { + try + { + using HttpResponseMessage response = await PostAsync(OpenAiLiveWire.AcceptPath(callId), body, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return AcceptOutcome.Accepted; + } + + string answer = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return answer.Contains(OpenAiLiveWire.DecisionAlreadyMade, StringComparison.Ordinal) ? AcceptOutcome.AlreadyDecided : AcceptOutcome.Failed; + } + catch (HttpRequestException) + { + return AcceptOutcome.Failed; + } + } + + internal Task RejectAsync(string callId, CallRefusal refusal, CancellationToken cancellationToken) => + SucceedsAsync(OpenAiLiveWire.RejectPath(callId), OpenAiLiveWire.RejectBody(refusal), cancellationToken); + + internal Task HangupAsync(string callId, CancellationToken cancellationToken) => + SucceedsAsync(OpenAiLiveWire.HangupPath(callId), [], cancellationToken); + + /// Hangs up on its own ; a deadline or a cancel gives , never an exception. + internal Task HangupQuietlyAsync(string callId) => QuietlyAsync(token => HangupAsync(callId, token)); + + /// Rejects on its own ; a deadline or a cancel gives , never an exception. + internal Task RejectQuietlyAsync(string callId, CallRefusal refusal) => QuietlyAsync(token => RejectAsync(callId, refusal, token)); + + private static async Task QuietlyAsync(Func> send) + { + using CancellationTokenSource limit = new(QuietDeadline); + try + { + return await send(limit.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return false; + } + } + + private async Task SucceedsAsync(string path, JsonObject body, CancellationToken cancellationToken) + { + try + { + using HttpResponseMessage response = await PostAsync(path, body, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + return false; + } + } + + private async Task PostAsync(string path, JsonObject body, CancellationToken cancellationToken) + { + OpenAiLiveCredentials keys = await credentials.ConfigureAwait(false); + using HttpRequestMessage request = new(HttpMethod.Post, path) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", keys.ApiKey); + return await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs new file mode 100644 index 00000000..6059dd08 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Nodes; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// + /// The sideband event names and shapes proven on the primary WebSocket. Whether the SIP + /// sideband uses the same ones is unconfirmed; a fact no probe proved goes in . + /// + internal static class OpenAiLiveEvents + { + internal const string InputTranscriptDelta = "session.input_transcript.delta"; + + internal const string OutputTranscriptDelta = "session.output_transcript.delta"; + + internal const string DelegationCreated = "session.delegation.created"; + + internal const string ThinkingAppend = "session.thinking.append"; + + internal const string CommentaryAppend = "session.commentary.append"; + + internal const string CommentaryAppended = "session.commentary.appended"; + + internal const string Closed = "session.closed"; + + /// Silent context for the open delegation. + internal static JsonObject Thinking(string delegationId, string content, string eventId) => Append(ThinkingAppend, delegationId, content, eventId); + + /// Words GPT-Live speaks for the open delegation, lightly paraphrased. At most 500 tokens. + internal static JsonObject Commentary(string delegationId, string content, string eventId) => Append(CommentaryAppend, delegationId, content, eventId); + + private static JsonObject Append(string type, string delegationId, string content, string eventId) => new() + { + ["type"] = type, + ["delegation_id"] = delegationId, + ["content"] = content, + ["event_id"] = eventId, + }; + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs new file mode 100644 index 00000000..b619911a --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs @@ -0,0 +1,154 @@ +using System.Collections.Frozen; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Gates; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// + /// Every fact about the OpenAI GPT-Live SIP wire that no probe proved. The probes ran on the primary + /// WebSocket, never on a SIP call. A real call must check each value here; correct them here + /// and nowhere else. + /// + internal static class OpenAiLiveWire + { + /// The error event's type. Unverified: no probe saw one; the shape { error: { code, message } } follows the Realtime API. + internal const string ErrorEvent = "error"; + + /// The code OpenAI answers a second accept or reject of one call with. + internal const string DecisionAlreadyMade = "decision_already_made"; + + internal static readonly Uri ApiBase = new("https://api.openai.com/"); + + /// The incoming-call webhook's type. The GPT-Live docs say the first, the SIP guide and openai-agents the second. + internal static readonly FrozenSet IncomingCallEvents = FrozenSet.ToFrozenSet(["live.transport.incoming", "realtime.call.incoming"], StringComparer.Ordinal); + + // URLs as the GPT-Live docs give them. + internal static string AcceptPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/accept"; + + internal static string RejectPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/reject"; + + internal static string HangupPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/hangup"; + + internal static Uri AttachUri(Uri apiBase, string callId) + { + ArgumentNullException.ThrowIfNull(apiBase); + UriBuilder attach = new(new Uri(apiBase, $"v1/live/sessions/{Uri.EscapeDataString(callId)}/attach")) + { + Scheme = apiBase.Scheme == Uri.UriSchemeHttp ? Uri.UriSchemeWs : Uri.UriSchemeWss, + }; + return attach.Uri; + } + + /// The SIP status a refusal is rejected with. + internal static int SipStatusOf(CallRefusal refusal) => refusal switch + { + CallRefusal.Declined => 603, + CallRefusal.Busy => 486, + _ => 503, + }; + + /// + /// The accept body: the proven session.start session shape from the probe logs, as the only field of the + /// body. A real SIP call proved the wrapper: a flat body is refused with "session must be the only field". + /// + internal static JsonObject AcceptBody(string model, string voice, string instructions) => new() + { + ["session"] = new JsonObject + { + ["model"] = model, + ["instructions"] = instructions, + ["audio"] = new JsonObject { ["output"] = new JsonObject { ["voice"] = voice } }, + ["delegation"] = new JsonObject { ["type"] = "client" }, + }, + }; + + /// + /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: instructions with no + /// delegation, from OpenAI's live-conversations guide. A real SIP call without it stayed silent until the caller spoke. + /// + internal static JsonObject GreetFirst(string eventId) => + SessionFact(eventId, "Begin the conversation now: greet the caller as your instructions say, then pause and listen."); + + /// + /// A fact for the whole session, under no delegation, from OpenAI's delegation guide: at most 500 tokens, such as + /// "The current date is December 10, 2024. Today is Tuesday." + /// + internal static JsonObject SessionFact(string eventId, string content) => new() + { + ["type"] = "session.instructions.append", + ["event_id"] = eventId, + ["delegation_id"] = null, + ["content"] = content, + }; + + internal static JsonObject RejectBody(CallRefusal refusal) => new() { ["status_code"] = SipStatusOf(refusal) }; + + /// Reads an incoming-call webhook body: { type, data: { call_id | session_id, sip_headers: [ { name, value } ] } }. + /// The call, or for any other event or a body that is not one. + internal static LiveIncomingCall? ReadIncomingCall(byte[] body) + { + ArgumentNullException.ThrowIfNull(body); + try + { + using JsonDocument document = JsonDocument.Parse(body); + JsonElement root = document.RootElement; + if (Text(root, "type") is not { } type || !IncomingCallEvents.Contains(type) + || !root.TryGetProperty("data", out JsonElement data) + || (Text(data, "call_id") ?? Text(data, "session_id")) is not { Length: > 0 } callId) + { + return null; + } + + Dictionary headers = new(StringComparer.OrdinalIgnoreCase); + if (data.TryGetProperty("sip_headers", out JsonElement list) && list.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement header in list.EnumerateArray()) + { + if (Text(header, "name") is { } name && Text(header, "value") is { } value) + { + headers[name] = headers.TryGetValue(name, out string? before) ? before + ", " + value : value; + } + } + } + + return new LiveIncomingCall(callId, SipUser(headers.GetValueOrDefault("From")), SipUser(headers.GetValueOrDefault("To")), headers); + } + catch (JsonException) + { + return null; + } + } + + /// The user part of a SIP or tel address: "Name" <sip:+15550100@host>;tag=1 gives +15550100. + internal static string? SipUser(string? header) + { + if (header is null) + { + return null; + } + + int scheme = header.IndexOf("sip:", StringComparison.OrdinalIgnoreCase); + if (scheme < 0) + { + scheme = header.IndexOf("tel:", StringComparison.OrdinalIgnoreCase); + } + + if (scheme < 0) + { + return null; + } + + int start = scheme + 4; + int end = header.IndexOfAny(['@', ';', '>'], start); + string user = end < 0 ? header[start..] : header[start..end]; + return user.Length == 0 ? null : user; + } + + private static string? Text(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/WebSocketSideband.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/WebSocketSideband.cs new file mode 100644 index 00000000..ed72ef3a --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/WebSocketSideband.cs @@ -0,0 +1,81 @@ +using System.Buffers; +using System.Net.WebSockets; +using System.Text; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire +{ + /// The sideband over a client WebSocket. + internal sealed class WebSocketSideband(WebSocket socket) : ILiveSideband + { + /// The largest message accepted. A transcript delta or a delegation is a few hundred bytes. + internal const int MaxMessageBytes = 1024 * 1024; + + private static readonly TimeSpan CloseWait = TimeSpan.FromSeconds(2); + + internal static async ValueTask ConnectAsync(LiveAttach attach, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(attach); + + ClientWebSocket client = new(); + try + { + client.Options.SetRequestHeader("Authorization", "Bearer " + attach.ApiKey); + await client.ConnectAsync(OpenAiLiveWire.AttachUri(attach.ApiBase, attach.CallId), cancellationToken).ConfigureAwait(false); + return new WebSocketSideband(client); + } + catch + { + client.Dispose(); + throw; + } + } + + /// A message passed . + public async ValueTask ReceiveAsync(CancellationToken cancellationToken) + { + ArrayBufferWriter message = new(); + while (true) + { + ValueWebSocketReceiveResult result = await socket.ReceiveAsync(message.GetMemory(4096), cancellationToken).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + return null; + } + + message.Advance(result.Count); + if (message.WrittenCount > MaxMessageBytes) + { + throw new InvalidDataException($"A sideband message passed {MaxMessageBytes} bytes."); + } + + if (result.EndOfMessage) + { + return Encoding.UTF8.GetString(message.WrittenSpan); + } + } + } + + public async ValueTask SendAsync(string json, CancellationToken cancellationToken) + { + await socket.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + using CancellationTokenSource wait = new(CloseWait); + try + { + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, statusDescription: null, wait.Token).ConfigureAwait(false); + } + catch (Exception fault) when (fault is WebSocketException or OperationCanceledException) + { + // The peer is already gone; there is nothing to close. + } + } + + socket.Dispose(); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/Telnyx/.gitkeep b/src/AgentCore.AspNetCore/Vendors/Telnyx/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/OutboundItem.cs similarity index 88% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/OutboundItem.cs index 656df3de..99f4e064 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/OutboundItem.cs @@ -1,4 +1,5 @@ -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection { /// One item queued for the write loop, carrying the reply generation it was written under. /// diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs similarity index 84% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs index 93e61cee..8c165f67 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs @@ -1,22 +1,23 @@ using System.Net.WebSockets; +using AgentCore.Application.Hooks.Gates; +using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.DependencyInjection; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Routing; +using AgentCore.AspNetCore.Voice.Transport; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.Domain.Audit; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection { /// /// One socket, one conversation. /// - /// - /// This type owns the socket and its teardown. and - /// translate the wire, and - /// owns the turns. - /// internal sealed class TelnyxRelayConnection : IAsyncDisposable { private readonly TelnyxRelayOptions _options; @@ -45,6 +46,12 @@ internal sealed class TelnyxRelayConnection : IAsyncDisposable private const string ConversationInUseDescription = "the conversation is in use by another entry"; + private const string HeldByCallDescription = "the conversation is held by another call"; + + private const string ReplacedDescription = "replaced by a newer connection for the same call"; + + private const string ShutdownCause = "shutdown"; + private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOptions options, ILogger logger) { _options = options; @@ -84,16 +91,19 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt _sender = new JsonWebSocketSender(socket, _options.CloseTimeout, _connectionToken); + AgentCoreBoot boot = http.RequestServices.GetRequiredService(); _loop = new VoiceConversationLoop( - http.RequestServices.GetRequiredService().Entries.Sessions, + PhoneCallHost.From(http.RequestServices, boot.Configuration.Providers?.Conversation), ConversationEndpointRouteBuilderExtensions.EntryOf(http), + TelnyxRelayConversationAdapter.TelnyxRelayKind, _output, _observer, timeProvider, logger, _options.CloseTimeout, _connectionToken, - _options.Voice); + _options.Voice, + Replaced); } private string ConversationIdForLog => _loop?.ConversationId ?? BeforeSetupConversationId; @@ -174,7 +184,7 @@ private async Task RunAsync() await _observer.ObserveAsync(writing, ConnectionTaskKind.WriteLoop).ConfigureAwait(false); - await _loop.EndAsync(EndReasonOf(status)).ConfigureAwait(false); + await _loop.EndAsync(EndReasonOf(status), _lifetime.ApplicationStopping.IsCancellationRequested ? ShutdownCause : null).ConfigureAwait(false); } finally { @@ -219,9 +229,14 @@ private async Task RunAsync() return (WebSocketCloseStatus.InternalServerError, null); } + if (_loop.WasReplaced) + { + return (WebSocketCloseStatus.NormalClosure, ReplacedDescription); + } + if (_loop.WasRefused) { - return (WebSocketCloseStatus.PolicyViolation, ConversationInUseDescription); + return (WebSocketCloseStatus.PolicyViolation, RefusalDescription(_loop.Refusal, _loop.HeldByCall)); } if (_lifetime.ApplicationStopping.IsCancellationRequested) @@ -242,6 +257,36 @@ private async Task RunAsync() return (WebSocketCloseStatus.NormalClosure, null); } + // A newer socket took this one's call over (LiveKit's duplicate identity): this socket goes, the call goes on. + private void Replaced() + { + _ = CancelAsReplacedAsync(); + } + + private async Task CancelAsReplacedAsync() + { + try + { + await _cancellation.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The connection already tore down. + } + catch (Exception fault) + { + ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.CancellationFaulted(_logger, ConversationIdForLog, fault)); + } + } + + private static string RefusalDescription(CallRefusal? refusal, bool heldByCall) => refusal switch + { + CallRefusal.Busy when heldByCall => HeldByCallDescription, + CallRefusal.Busy => ConversationInUseDescription, + CallRefusal.Declined => "the call was declined", + _ => "the call could not be taken now", + }; + /// 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. @@ -282,13 +327,6 @@ private bool ClassifyTelnyxFault(Exception fault, ConnectionTaskKind kind, strin /// Which task faulted. /// The id of the conversation, or a placeholder before setup. /// The cause. - /// - /// The logFault hook of , which calls it from - /// directly and from the fault-only - /// continuation that method attaches on a timeout. That continuation runs with nothing above it - /// to catch a throw, so the observer wraps every call to this method in - /// ; nothing here has to guard itself again. - /// private void LogFault(ConnectionTaskKind kind, string conversationId, Exception fault) { switch (kind) diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConversationChannelFactory.cs similarity index 57% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConversationChannelFactory.cs index b4bcee8a..27a0a22a 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConversationChannelFactory.cs @@ -1,6 +1,7 @@ -using AgentCore.AspNetCore.Voice; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +using AgentCore.AspNetCore.Voice.Routing; + +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection { /// Opens a Telnyx relay channel over the two halves of one connection. /// The caller half of the connection that accepted this conversation's socket. @@ -9,12 +10,6 @@ internal sealed class TelnyxRelayConversationChannelFactory(TelnyxRelayInput inp : IConversationChannelFactory { /// - /// - /// is read for nothing, and the conversation id in it least of all: this - /// vendor names the conversation on its own setup frame, and the connection is already answering that - /// conversation by the time anything here could run. A future factory that really does open a conversation — - /// the split adapter's — is where those values start to matter. - /// public ValueTask OpenAsync( ConversationChannelContext context, CancellationToken cancellationToken = default) diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs similarity index 93% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs index 7a854628..20e99214 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs @@ -1,9 +1,10 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; +using AgentCore.AspNetCore.Voice.Ports; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection { /// /// The caller half of one relay socket: turns each inbound frame into a . @@ -35,7 +36,12 @@ public Task AcceptAsync(RelayFrame frame) { case RelayFrame.Setup setup: _sawSetup = true; - return PublishAsync(new ConversationInput.Started(setup.ConversationSessionId)); + return PublishAsync(new ConversationInput.Started(setup.ConversationSessionId) + { + From = setup.From, + To = setup.To, + Headers = setup.CustomParameters, + }); case RelayFrame.Prompt prompt: return PublishAsync(new ConversationInput.Utterance(prompt.VoicePrompt, prompt.Lang, prompt.Last)); diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayOutput.cs similarity index 96% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayOutput.cs index e2613741..628423b1 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayOutput.cs @@ -1,8 +1,9 @@ using System.Text.Json; using System.Threading.Channels; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; +using AgentCore.AspNetCore.Voice.Ports; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection { /// /// The reply half of one relay socket: queues text frames for the write loop, and drops every @@ -35,7 +36,6 @@ internal sealed class TelnyxRelayOutput : IConversationOutputPort 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(); diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs index d84ff430..fd81e831 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs @@ -1,6 +1,7 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Ports; using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay @@ -8,17 +9,6 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay /// /// The Telnyx Conversation Relay as a conversation transport: it owns the socket and speaks Telnyx frames. /// - /// - /// - /// D28 buys the whole speech layer — recognition, turn detection, synthesis, and interruption — - /// inside the relay, so is and both speech roles, - /// providers.speech.stt and providers.speech.tts, must name this same vendor. - /// - /// - /// This owns only the route. TelnyxRelayConnection is still what a conversation runs on, and - /// TelnyxRelayConversationChannelFactory is still what hands out its two ports. - /// - /// public sealed class TelnyxRelayConversationAdapter : IConversationTransportAdapter { /// The one providers.conversation.kind value this vendor answers to. @@ -28,7 +18,6 @@ public sealed class TelnyxRelayConversationAdapter : IConversationTransportAdapt public string Kind => TelnyxRelayKind; /// - /// The relay's frames carry text: the vendor performs recognition and synthesis itself. public bool CarriesText => true; /// @@ -47,11 +36,6 @@ public RequestDelegate CreateHandler(ConversationProviderConfiguration configura /// A value would be refused at run time by Task.Delay, CancelAfter, or /// Task.WaitAsync, or a frame cap is not positive. The pointer names the exact field. /// - /// - /// It throws and not - /// because the value came from a document rather than - /// from a C# caller, and a reader needs the line to fix rather than a property name. - /// internal static TelnyxRelayOptions BuildOptions(ConversationProviderConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs index e9362b2b..b62c3d21 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs @@ -1,4 +1,5 @@ using System.Net.WebSockets; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -12,7 +13,7 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay internal static class TelnyxRelayEndpointRouteBuilderExtensions { /// The route the test host maps this endpoint on. - public const string DefaultPattern = "/v1/{" + AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter + "}/telnyx/relay"; + public const string DefaultPattern = "/v1/{" + AgentCore.AspNetCore.Voice.Routing.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter + "}/telnyx/relay"; /// Maps the socket on one route, with the limits the host chose. /// The route builder of the host. diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs index e6e8b990..2236a707 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs @@ -1,3 +1,4 @@ +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; using Microsoft.Extensions.Logging; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay @@ -7,7 +8,7 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay /// internal static partial class TelnyxRelayLog { - /// Section 3.1. An unmodelled frame type, logged once for the conversation. + /// An unmodelled frame type, logged once for the conversation. /// The logger of the connection. /// The type value no case matched. /// The id of the conversation, or a placeholder before setup. @@ -85,7 +86,7 @@ internal static partial class TelnyxRelayLog Message = "cancelling the connection token of conversation {ConversationId} faulted.")] public static partial void CancellationFaulted(ILogger logger, string conversationId, Exception exception); - /// A malformed interrupt frame was refused. Section 7.1: a bad frame must not drop the conversation. + /// A malformed interrupt frame was refused; a bad frame must not drop the conversation. /// The logger of the connection. /// The id of the conversation. [LoggerMessage( @@ -143,7 +144,7 @@ internal static partial class TelnyxRelayLog [LoggerMessage( EventId = 22, 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.")] + Message = "the session of conversation {ConversationId} could not be closed, so its last words may never reach the message store and it waits out the idle timeout.")] public static partial void ConversationCloseFaulted(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 63240a0a..6125f635 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs @@ -1,4 +1,5 @@ -using AgentCore.AspNetCore.Voice; + +using AgentCore.AspNetCore.Voice.Options; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay { diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs index 65282c6b..d0986256 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs @@ -1,4 +1,5 @@ -using AgentCore.AspNetCore.Voice; + +using AgentCore.AspNetCore.Voice.Ports; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay { diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/RelayProtocolException.cs similarity index 90% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/RelayProtocolException.cs index fe091591..cc02b6c3 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/RelayProtocolException.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire { /// The relay broke the contract, and the socket must close with a reason. /// The close status the vendor should see. diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayContracts.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayContracts.cs similarity index 74% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayContracts.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayContracts.cs index b8c7fc04..5349e084 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayContracts.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayContracts.cs @@ -1,27 +1,16 @@ -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire { /// /// One frame of the Telnyx Conversation Relay socket, inbound. /// - /// - /// - /// Section 7.1 makes this a wire format and not a port. The vendor boundary moved from audio to - /// text with D28, so no frame here carries a buffer, a codec, a sample rate, or a playback clock. - /// Item 6c of section 11 asks for exactly that, and this file is where it holds. - /// - /// - /// One discriminator names the frame, as section 3.2 taught: model the field, not one type for - /// each value. These records stay in the adapter, and D8 keeps them out of the core. - /// - /// internal abstract record RelayFrame { /// The first frame of a conversation. It names every id the conversation will need. /// The relay session. /// The conversation, in the vendor vocabulary. /// - /// The handle the Conversation Control API accepts. Slice 2 needs it for the conference warm transfer - /// of section 4.3, and this frame is the only place it reaches the socket. + /// The handle the Conversation Control API accepts. A conference warm transfer needs it, + /// and this frame is the only place it reaches the socket. /// /// /// The group of legs that belong to one logical conversation. It becomes the AgentCore conversation id, @@ -53,7 +42,7 @@ internal sealed record Prompt(string VoicePrompt, string Lang, bool Last) : Rela /// The text the caller actually heard. /// /// How much of the reply played, in milliseconds, as the vendor measured it. Neither value is - /// estimated, which is the whole of D28. Item 6a of section 11 asks for the first one. + /// estimated. /// internal sealed record Interrupt(string UtteranceUntilInterrupt, int DurationUntilInterruptMs) : RelayFrame; @@ -69,11 +58,6 @@ internal sealed record Error(string Description) : RelayFrame; /// One piece of the reply, outbound. /// The text to speak. /// Whether this piece closes the reply. - /// - /// The vendor needs the discriminator on every outbound frame, and it counts a frame without one - /// as malformed. Repeated malformed frames make the vendor close the socket, so - /// is a property and not a caller's responsibility. - /// internal sealed record RelayToken(string Token, bool Last) { /// Gets the wire discriminator. diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayFrameReader.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayFrameReader.cs similarity index 64% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayFrameReader.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayFrameReader.cs index db4e9897..791c832e 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayFrameReader.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayFrameReader.cs @@ -1,32 +1,10 @@ using System.Text.Json; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire { /// /// Reads one inbound relay frame, and never throws on what the vendor sent. /// - /// - /// - /// The switch below is written by hand on purpose. System.Text.Json polymorphic deserialization - /// cannot express the rule of section 7.1, because its ignore option reverts to the contract of - /// the base type and is abstract. It also wants the discriminator first, - /// and nothing in the vendor contract promises that. - /// - /// - /// A vendor that adds a frame must not be able to drop a conversation, so an unmodelled type is refused - /// and named rather than thrown. The caller logs the name once for the conversation, not once for the - /// frame, per section 3.1. - /// - /// - /// A vendor that changes a frame must not be able to drop one either, and section 7.1 draws - /// no line between the two. A known type whose body will not bind is therefore a third - /// answer, apart from both "here is your frame" and "these bytes are not readable at all": the - /// frame is refused, the socket lives, and the conversation goes on. Two shapes reach it from the live - /// wire — a decimal durationUntilInterruptMs, which would otherwise end a conversation at the exact - /// moment of a barge-in, and a non-string value inside customParameters, which would - /// otherwise end it on the setup frame before it began. - /// - /// internal static class TelnyxRelayFrameReader { /// Reads one frame from the bytes of one WebSocket message. @@ -43,11 +21,6 @@ internal static class TelnyxRelayFrameReader /// the vendor, and a refused body is a field of a frame this build does know. /// /// when holds a frame. - /// - /// A answer with both names is the only - /// unreadable case: the bytes are not JSON, not an object, or carry no string type. That - /// is the one the caller may close the socket over. - /// public static bool TryRead( ReadOnlySpan utf8, out RelayFrame? frame, @@ -95,7 +68,7 @@ public static bool TryRead( catch (JsonException) { // The type is one this build knows, so the bytes are readable and only a field is - // wrong. Section 7.1: that is a refused frame, never a refused conversation. + // wrong. That is a refused frame, never a refused conversation. refusedType = name; return false; } diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayJson.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayJson.cs similarity index 57% rename from src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayJson.cs rename to src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayJson.cs index 9d075c77..4162b16f 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayJson.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Wire/TelnyxRelayJson.cs @@ -1,16 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire { /// /// The one serializer setting of the relay wire. /// - /// - /// gives camelCase names and case-insensitive reads, - /// which is exactly what the vendor sends and expects. One shared instance avoids the per-conversation - /// options cache that a fresh instance would rebuild for every frame. - /// internal static class TelnyxRelayJson { /// Gets the options every read and every write of this wire uses. diff --git a/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs b/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs deleted file mode 100644 index 2c8167a9..00000000 --- a/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace AgentCore.AspNetCore.Voice -{ - /// - /// The two halves of one conversation, opened together and disposed together. - /// - /// - /// - /// A vendor that does both halves itself returns one object in both slots. A pair of separate - /// services returns two. Nothing above this type may ask which it got, and nothing above it can - /// tell: one, two, or two sharing private state all read the same here. - /// - /// - /// The bundled shape is why both ports speak text. Conversation Relay runs the audio on its own side of the - /// wire: transcripts arrive as text, replies leave as text, and the barge-in answer — what the - /// caller heard, and for how long — arrives already measured, in the vendor's own frame. One - /// connection is honestly both halves, so the factory hands the same object to both slots. - /// - /// - /// caller ─audio─▶ [ conversation relay ] ─text─▶ RelayConnection ─▶ agent - /// caller ◀─audio─ [ conversation relay ] ◀─text─ RelayConnection ◀─ agent reply - /// - /// - /// Two objects at the interface does not mean two independent objects underneath. A split adapter - /// measures the played duration on its speaking side and reports it on its listening side, so its - /// two halves share one clock. That sharing belongs to whichever adapter created them, which is - /// why it appears on neither interface. - /// - /// - /// That clock is the layer of indirection a split adapter must add. Separate STT and TTS services - /// merge nothing, so the adapter wraps the two vendor clients — the - /// Microsoft.Extensions.AI pair, ISpeechToTextClient and ITextToSpeechClient, - /// which stay inside the adapter and never enter this assembly, because they sit at the audio - /// boundary and are marked experimental — and computes for itself what the bundled vendor - /// reported for free: bytes written minus the transport's own queue, counted from the first - /// audibly played sound, mapped back to the words the model wrote — measured, never estimated. - /// The settled answer still arrives as a on the input stream, and - /// every settle path after carries a two-second - /// deadline, after which the reply counts as fully heard. Sections 2, 6, and 11 of the design - /// record the argument. - /// - /// - /// caller ─audio─▶ [ STT client ] ─text─▶ split input port ─┐ - /// ├─ ConversationChannel ─▶ agent - /// caller ◀─audio─ [ TTS client ] ◀─text─ split output port ─┘ - /// │ - /// [ playback clock ] - /// - /// - /// What the caller does. - /// What the agent says. - public sealed record ConversationChannel(IConversationInputPort Input, IConversationOutputPort Output) : IAsyncDisposable - { - private bool _disposed; - - /// Disposes both halves, and each underlying object exactly once. - public async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - _disposed = true; - - await Input.DisposeAsync().ConfigureAwait(false); - - if (!ReferenceEquals(Input, Output)) - { - await Output.DisposeAsync().ConfigureAwait(false); - } - } - } -} diff --git a/src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs b/src/AgentCore.AspNetCore/Voice/Diagnostics/ConversationRouteLog.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs rename to src/AgentCore.AspNetCore/Voice/Diagnostics/ConversationRouteLog.cs index 2f3b1fcb..cefac57a 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs +++ b/src/AgentCore.AspNetCore/Voice/Diagnostics/ConversationRouteLog.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Diagnostics { /// The lines the conversation route writes while the host starts. internal static partial class ConversationRouteLog diff --git a/src/AgentCore.AspNetCore/Voice/VoiceConversationLog.cs b/src/AgentCore.AspNetCore/Voice/Diagnostics/VoiceConversationLog.cs similarity index 86% rename from src/AgentCore.AspNetCore/Voice/VoiceConversationLog.cs rename to src/AgentCore.AspNetCore/Voice/Diagnostics/VoiceConversationLog.cs index c6d3dbda..ca48547d 100644 --- a/src/AgentCore.AspNetCore/Voice/VoiceConversationLog.cs +++ b/src/AgentCore.AspNetCore/Voice/Diagnostics/VoiceConversationLog.cs @@ -1,6 +1,8 @@ +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Diagnostics { /// /// Every line and the speech types under it write. @@ -34,16 +36,6 @@ internal static partial class VoiceConversationLog + "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 that owns the loop. - /// The id of the conversation whose chain did not close. - /// The cause. - [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 store could not be told that a conversation is still being had. /// The logger. /// The id of the conversation. @@ -145,16 +137,6 @@ internal static partial class VoiceConversationLog Message = "the away prompt was not spoken, speech scheduling had already stopped for the close.")] public static partial void UserAwayPromptNotScheduled(ILogger logger, Exception exception); - /// The transport named a conversation another entry holds live, so nothing was opened. - /// The logger of the connection that owns the loop. - /// The id of the conversation the transport named. - /// The entry the transport arrived on. - [LoggerMessage( - EventId = 36, - Level = LogLevel.Warning, - Message = "conversation {ConversationId} is in use by another entry, so entry {Entry} refused the transport.")] - public static partial void ConversationInUse(ILogger logger, string conversationId, string entry); - /// The engine refused a cut, so the turn's history stays as it was. /// The logger of the voice session. /// The turn the cut named. @@ -172,5 +154,13 @@ internal static partial class VoiceConversationLog Level = LogLevel.Information, Message = "the recut of turn {TurnIndex} was refused: the turn took no cut, or a later turn had started, so it keeps the cut it took.")] public static partial void RecutRefused(ILogger logger, int turnIndex); + + /// A barge-in report for a reply came once its barge window had closed, so it changes neither the line nor the turn. + /// The logger of the voice session. + [LoggerMessage( + EventId = 37, + Level = LogLevel.Information, + Message = "a barge-in report came after the reply's barge window closed, so what the caller heard of that reply stays as it was.")] + public static partial void BargeReportTooLate(ILogger logger); } } diff --git a/src/AgentCore.AspNetCore/Voice/FillerOptions.cs b/src/AgentCore.AspNetCore/Voice/Filler/FillerOptions.cs similarity index 82% rename from src/AgentCore.AspNetCore/Voice/FillerOptions.cs rename to src/AgentCore.AspNetCore/Voice/Filler/FillerOptions.cs index bba25e65..56eb200f 100644 --- a/src/AgentCore.AspNetCore/Voice/FillerOptions.cs +++ b/src/AgentCore.AspNetCore/Voice/Filler/FillerOptions.cs @@ -1,10 +1,10 @@ // 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. +// C#; a tool cannot open this itself, so it is voice-options input to ToolFillerScope instead of a +// RunContext call. Per tool id, off by default. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Filler { /// One tool id's filler: what to say while its step is still running, and how often. /// What to say. See . diff --git a/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs b/src/AgentCore.AspNetCore/Voice/Filler/FillerScheduler.cs similarity index 96% rename from src/AgentCore.AspNetCore/Voice/FillerScheduler.cs rename to src/AgentCore.AspNetCore/Voice/Filler/FillerScheduler.cs index c5900f8b..ad86fb5d 100644 --- a/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs +++ b/src/AgentCore.AspNetCore/Voice/Filler/FillerScheduler.cs @@ -1,9 +1,14 @@ // 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). +// (its only caller, ctx.update() for async tools, is not ported). -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Threading; + +namespace AgentCore.AspNetCore.Voice.Filler { /// /// Fires filler speech after the session has been continuously idle for a dwell, while the @@ -73,7 +78,6 @@ public FillerScheduler( 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) diff --git a/src/AgentCore.AspNetCore/Voice/FillerSource.cs b/src/AgentCore.AspNetCore/Voice/Filler/FillerSource.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/FillerSource.cs rename to src/AgentCore.AspNetCore/Voice/Filler/FillerSource.cs index e4c1d6c7..14890f64 100644 --- a/src/AgentCore.AspNetCore/Voice/FillerSource.cs +++ b/src/AgentCore.AspNetCore/Voice/Filler/FillerSource.cs @@ -2,7 +2,9 @@ // (_FillerSource), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Speech; + +namespace AgentCore.AspNetCore.Voice.Filler { /// What a filler says: fixed text, or a callable invoked lazily at fire time with the fire count so far. internal readonly struct FillerSource diff --git a/src/AgentCore.AspNetCore/Voice/ToolFillerScope.cs b/src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs similarity index 84% rename from src/AgentCore.AspNetCore/Voice/ToolFillerScope.cs rename to src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs index cef97a91..f8ab9341 100644 --- a/src/AgentCore.AspNetCore/Voice/ToolFillerScope.cs +++ b/src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs @@ -1,11 +1,14 @@ // Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:112-153 // (RunContext.with_filler), 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 a scope PipelineReply opens from voice options on a FunctionCallContent and -// closes on the matching FunctionResultContent or the speech's interruption (plan 2.4), rather than an +// this itself, so it is a scope PipelineReply opens from voice options on a FunctionCallContent and +// closes on the matching FunctionResultContent or the speech's interruption, rather than an // async context manager a tool's own code enters. -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; + +namespace AgentCore.AspNetCore.Voice.Filler { /// The filler scheduled for one tool call, open for as long as its step is still running. internal sealed class ToolFillerScope : IAsyncDisposable @@ -14,7 +17,7 @@ internal sealed class ToolFillerScope : IAsyncDisposable /// Where idleness, state changes, and say come from. /// The speech this tool call's step belongs to. - /// This tool id's filler, from voice options (plan Q1/owner ruling). + /// This tool id's filler, from voice options. public ToolFillerScope(VoiceSession session, SpeechHandle speechHandle, FillerOptions options) { _scheduler = new FillerScheduler(session, speechHandle, options.Source, options.Delay, options.Interval, options.MaxSteps); diff --git a/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs b/src/AgentCore.AspNetCore/Voice/Options/BoundedTimerLimits.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs rename to src/AgentCore.AspNetCore/Voice/Options/BoundedTimerLimits.cs index 2c9d8bfb..924b4c80 100644 --- a/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs +++ b/src/AgentCore.AspNetCore/Voice/Options/BoundedTimerLimits.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Options { /// The longest span any -backed wait in this assembly accepts. internal static class BoundedTimerLimits diff --git a/src/AgentCore.AspNetCore/Voice/VoiceOptions.cs b/src/AgentCore.AspNetCore/Voice/Options/VoiceOptions.cs similarity index 89% rename from src/AgentCore.AspNetCore/Voice/VoiceOptions.cs rename to src/AgentCore.AspNetCore/Voice/Options/VoiceOptions.cs index 0df8bf81..c8826898 100644 --- a/src/AgentCore.AspNetCore/Voice/VoiceOptions.cs +++ b/src/AgentCore.AspNetCore/Voice/Options/VoiceOptions.cs @@ -1,4 +1,8 @@ -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Session; + +namespace AgentCore.AspNetCore.Voice.Options { /// What one voice conversation does on its own: the away prompt and the per-tool filler. /// When the caller counts as away and what the agent says then, or to disable it. diff --git a/src/AgentCore.AspNetCore/Voice/VoiceOptionsBinder.cs b/src/AgentCore.AspNetCore/Voice/Options/VoiceOptionsBinder.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/VoiceOptionsBinder.cs rename to src/AgentCore.AspNetCore/Voice/Options/VoiceOptionsBinder.cs index e4d71634..b116fa6d 100644 --- a/src/AgentCore.AspNetCore/Voice/VoiceOptionsBinder.cs +++ b/src/AgentCore.AspNetCore/Voice/Options/VoiceOptionsBinder.cs @@ -2,8 +2,10 @@ using System.Text.Json; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Session; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Options { /// /// Turns providers.conversation's userAway and filler into a . diff --git a/src/AgentCore.AspNetCore/Voice/Ports/CallTraits.cs b/src/AgentCore.AspNetCore/Voice/Ports/CallTraits.cs new file mode 100644 index 00000000..581245db --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/Ports/CallTraits.cs @@ -0,0 +1,16 @@ +namespace AgentCore.AspNetCore.Voice.Ports +{ + /// What a conversation vendor does itself, so AgentCore does not. + [Flags] + public enum CallTraits + { + /// AgentCore's own speech layer runs the conversation. + None = 0, + + /// The vendor makes the caller's audio itself and takes text from AgentCore, so no providers.speech is needed. + SpeaksForItself = 1, + + /// The vendor decides when the caller's turn ends, so AgentCore's away prompt and tool fillers never run. + TakesTurnsItself = 2, + } +} diff --git a/src/AgentCore.AspNetCore/Voice/ConversationInput.cs b/src/AgentCore.AspNetCore/Voice/Ports/ConversationInput.cs similarity index 77% rename from src/AgentCore.AspNetCore/Voice/ConversationInput.cs rename to src/AgentCore.AspNetCore/Voice/Ports/ConversationInput.cs index b3218e95..3ee36559 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationInput.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/ConversationInput.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// /// One thing the caller did, in the order it happened. @@ -13,7 +13,17 @@ private protected ConversationInput() /// /// 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; + public sealed record Started(string ConversationId) : ConversationInput + { + /// Gets the caller's number, when the transport names it. + public string? From { get; init; } + + /// Gets the number the caller dialled, when the transport names it. + public string? To { get; init; } + + /// Gets the transport's per-call values, such as the relay's custom parameters, or . + public IReadOnlyDictionary? Headers { get; init; } + } /// The caller spoke. /// What the recognizer heard. @@ -35,8 +45,7 @@ public sealed record Keypress(string Key) : ConversationInput; /// leaves the synthesizer. A consumer must not record an assistant turn for an empty value. /// /// - /// How much of the reply played. Measured, never estimated: D28 forbids the estimator that - /// would otherwise stand here, and item 6c of section 11 asks for the same. + /// How much of the reply played. Measured, never estimated. /// public sealed record Barge(string HeardText, TimeSpan PlayedDuration) : ConversationInput; } diff --git a/src/AgentCore.AspNetCore/Voice/FrameOutcome.cs b/src/AgentCore.AspNetCore/Voice/Ports/FrameOutcome.cs similarity index 92% rename from src/AgentCore.AspNetCore/Voice/FrameOutcome.cs rename to src/AgentCore.AspNetCore/Voice/Ports/FrameOutcome.cs index 1457a8f0..90d47bd5 100644 --- a/src/AgentCore.AspNetCore/Voice/FrameOutcome.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/FrameOutcome.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// What reading one inbound message produced. /// The parsed frame, or when none was read. diff --git a/src/AgentCore.AspNetCore/Voice/FrameParser.cs b/src/AgentCore.AspNetCore/Voice/Ports/FrameParser.cs similarity index 88% rename from src/AgentCore.AspNetCore/Voice/FrameParser.cs rename to src/AgentCore.AspNetCore/Voice/Ports/FrameParser.cs index 50511947..f82cc2ab 100644 --- a/src/AgentCore.AspNetCore/Voice/FrameParser.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/FrameParser.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// Reads one reassembled inbound message into a . /// The whole message, already reassembled. diff --git a/src/AgentCore.AspNetCore/Voice/IConversationAdapter.cs b/src/AgentCore.AspNetCore/Voice/Ports/IConversationAdapter.cs similarity index 67% rename from src/AgentCore.AspNetCore/Voice/IConversationAdapter.cs rename to src/AgentCore.AspNetCore/Voice/Ports/IConversationAdapter.cs index eb9d0f41..a8a9e298 100644 --- a/src/AgentCore.AspNetCore/Voice/IConversationAdapter.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/IConversationAdapter.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Ports; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// /// Names the vendor behind one providers.conversation value: who carries the conversation. @@ -11,5 +11,8 @@ public interface IConversationAdapter : IVendorAdapter /// Gets whether this transport's frames already carry text. /// bool CarriesText { get; } + + /// Gets what this vendor does itself. AgentCore's startup checks read it. + CallTraits Traits => CallTraits.None; } } diff --git a/src/AgentCore.AspNetCore/Voice/IConversationInputPort.cs b/src/AgentCore.AspNetCore/Voice/Ports/IConversationInputPort.cs similarity index 90% rename from src/AgentCore.AspNetCore/Voice/IConversationInputPort.cs rename to src/AgentCore.AspNetCore/Voice/Ports/IConversationInputPort.cs index 1a19a0a9..facf309d 100644 --- a/src/AgentCore.AspNetCore/Voice/IConversationInputPort.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/IConversationInputPort.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// /// What the caller did, for one conversation, as one ordered stream. @@ -7,7 +7,7 @@ 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. + /// Abandons the read. Cancelling it throws, and never ends the stream quietly. /// /// Every inbound event of the conversation, in order. /// This port is already being read. diff --git a/src/AgentCore.AspNetCore/Voice/IConversationOutputPort.cs b/src/AgentCore.AspNetCore/Voice/Ports/IConversationOutputPort.cs similarity index 96% rename from src/AgentCore.AspNetCore/Voice/IConversationOutputPort.cs rename to src/AgentCore.AspNetCore/Voice/Ports/IConversationOutputPort.cs index 9cd70e52..a4713684 100644 --- a/src/AgentCore.AspNetCore/Voice/IConversationOutputPort.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/IConversationOutputPort.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// /// What the agent says, for one conversation. diff --git a/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs b/src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs similarity index 91% rename from src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs rename to src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs index 3e7000d3..b3bae1f1 100644 --- a/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs @@ -1,7 +1,8 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.Voice.Routing; using Microsoft.AspNetCore.Http; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// /// A conversation vendor this process is dialled in to, which therefore owns an inbound route. diff --git a/src/AgentCore.AspNetCore/Voice/ISpeechAdapter.cs b/src/AgentCore.AspNetCore/Voice/Ports/ISpeechAdapter.cs similarity index 84% rename from src/AgentCore.AspNetCore/Voice/ISpeechAdapter.cs rename to src/AgentCore.AspNetCore/Voice/Ports/ISpeechAdapter.cs index b8375aa9..13d23db8 100644 --- a/src/AgentCore.AspNetCore/Voice/ISpeechAdapter.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/ISpeechAdapter.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Ports; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Ports { /// /// Names the vendor behind one speech role's kind, stt or tts. diff --git a/src/AgentCore.AspNetCore/Voice/Routing/ConversationChannel.cs b/src/AgentCore.AspNetCore/Voice/Routing/ConversationChannel.cs new file mode 100644 index 00000000..5515c915 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/Routing/ConversationChannel.cs @@ -0,0 +1,32 @@ +using AgentCore.AspNetCore.Voice.Ports; + +namespace AgentCore.AspNetCore.Voice.Routing +{ + /// + /// The two halves of one conversation, opened together and disposed together. + /// + /// What the caller does. + /// What the agent says. + public sealed record ConversationChannel(IConversationInputPort Input, IConversationOutputPort Output) : IAsyncDisposable + { + private bool _disposed; + + /// Disposes both halves, and each underlying object exactly once. + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + await Input.DisposeAsync().ConfigureAwait(false); + + if (!ReferenceEquals(Input, Output)) + { + await Output.DisposeAsync().ConfigureAwait(false); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs b/src/AgentCore.AspNetCore/Voice/Routing/ConversationChannelContext.cs similarity index 90% rename from src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs rename to src/AgentCore.AspNetCore/Voice/Routing/ConversationChannelContext.cs index 0b5467b1..4e86e417 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs +++ b/src/AgentCore.AspNetCore/Voice/Routing/ConversationChannelContext.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Routing { /// What one conversation needs to open its channel. /// The AgentCore conversation id, which the channel must not invent for itself. diff --git a/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs b/src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs similarity index 91% rename from src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs rename to src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs index fe68da19..6e7ac3fc 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs +++ b/src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs @@ -1,6 +1,7 @@ using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Voice.Diagnostics; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -9,7 +10,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Routing { /// /// Maps the inbound conversation route onto whichever transport the document names. @@ -33,9 +34,8 @@ public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endp /// Maps every entry on one route. /// The route builder of the host. /// - /// The route to answer on. It carries the {entry} parameter, or the host attaches an - /// with . - /// Startup fails when it has neither. + /// The route to answer on. It carries the {entry} parameter, or a hook's + /// picks the entry. Startup fails when it has neither. /// /// The mapped endpoint, so a host adds its own conventions. public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endpoints, string pattern) @@ -45,7 +45,7 @@ public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endp // Map, and not MapGet. An HTTP/2 WebSocket arrives as CONNECT rather than GET, and MapGet // would answer 405 to it. - return endpoints.Map(pattern, http => DispatchAsync(http, pattern)).WithMetadata(AgentCoreRouteMetadata.Instance); + return endpoints.Map(pattern, http => DispatchAsync(http, pattern)).WithMetadata(AgentCoreRouteMetadata.Call); } /// Reads the entry this request runs. @@ -57,7 +57,7 @@ public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endp public static string EntryOf(HttpContext http) { ArgumentNullException.ThrowIfNull(http); - return http.Features.Get()?.Entry + return AgentCoreEntries.Known(http) ?? http.Request.RouteValues[EntryRouteParameter] as string ?? string.Empty; } @@ -82,7 +82,7 @@ await NotRoutedAsync(http, pattern, boot.ConversationUnroutable ?? "this host ro { await WriteProblemAsync( http, StatusCodes.Status403Forbidden, "This route runs no entry for this caller.", - "entry_refused: the route's entry selector refused this caller.", http.Request.Path) + "entry_refused: the entry gate refused this caller.", http.Request.Path) .ConfigureAwait(false); return; } diff --git a/src/AgentCore.AspNetCore/Voice/ConversationSeams.cs b/src/AgentCore.AspNetCore/Voice/Routing/ConversationSeams.cs similarity index 90% rename from src/AgentCore.AspNetCore/Voice/ConversationSeams.cs rename to src/AgentCore.AspNetCore/Voice/Routing/ConversationSeams.cs index 4f1ac14b..6ff010d9 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationSeams.cs +++ b/src/AgentCore.AspNetCore/Voice/Routing/ConversationSeams.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Providers; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Routing { /// The names the conversation seam uses in every failure it raises. internal static class ConversationSeams diff --git a/src/AgentCore.AspNetCore/Voice/IConversationChannelFactory.cs b/src/AgentCore.AspNetCore/Voice/Routing/IConversationChannelFactory.cs similarity index 92% rename from src/AgentCore.AspNetCore/Voice/IConversationChannelFactory.cs rename to src/AgentCore.AspNetCore/Voice/Routing/IConversationChannelFactory.cs index fc47a7eb..0768489b 100644 --- a/src/AgentCore.AspNetCore/Voice/IConversationChannelFactory.cs +++ b/src/AgentCore.AspNetCore/Voice/Routing/IConversationChannelFactory.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Routing { /// Opens the channel for one conversation. public interface IConversationChannelFactory diff --git a/src/AgentCore.AspNetCore/Voice/AgentState.cs b/src/AgentCore.AspNetCore/Voice/Session/AgentState.cs similarity index 95% rename from src/AgentCore.AspNetCore/Voice/AgentState.cs rename to src/AgentCore.AspNetCore/Voice/Session/AgentState.cs index a1d555af..d9462b57 100644 --- a/src/AgentCore.AspNetCore/Voice/AgentState.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/AgentState.cs @@ -2,7 +2,7 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Session { /// What the agent is doing right now. internal enum AgentState diff --git a/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs b/src/AgentCore.AspNetCore/Voice/Session/AgentStateChanged.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs rename to src/AgentCore.AspNetCore/Voice/Session/AgentStateChanged.cs index b1913929..4a1c0938 100644 --- a/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/AgentStateChanged.cs @@ -3,7 +3,7 @@ // 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 +namespace AgentCore.AspNetCore.Voice.Session { /// Raised each time 's agent state actually changes. /// The state before the change. diff --git a/src/AgentCore.AspNetCore/Voice/UserAwayOptions.cs b/src/AgentCore.AspNetCore/Voice/Session/UserAwayOptions.cs similarity index 86% rename from src/AgentCore.AspNetCore/Voice/UserAwayOptions.cs rename to src/AgentCore.AspNetCore/Voice/Session/UserAwayOptions.cs index a2784222..b85908fc 100644 --- a/src/AgentCore.AspNetCore/Voice/UserAwayOptions.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/UserAwayOptions.cs @@ -1,12 +1,14 @@ // Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_session.py:413 // (user_away_timeout default), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, // Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; LiveKit only emits -// user_state_changed and leaves "away" to the app. What to say is ours, per the owner's ruling on plan -// question 2: a fixed prompt, not a hook. +// user_state_changed and leaves "away" to the app. What to say is ours: +// a fixed prompt, not a hook. -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Options; + +namespace AgentCore.AspNetCore.Voice.Session { - /// When the caller counts as away, and what the agent says once they do (plan Q2/owner ruling). + /// When the caller counts as away, and what the agent says once they do. /// /// How long both sides must stay / /// before the caller is marked away. uses 15 s, LiveKit's default, diff --git a/src/AgentCore.AspNetCore/Voice/UserAwayTimer.cs b/src/AgentCore.AspNetCore/Voice/Session/UserAwayTimer.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/UserAwayTimer.cs rename to src/AgentCore.AspNetCore/Voice/Session/UserAwayTimer.cs index 01be17b5..614b733f 100644 --- a/src/AgentCore.AspNetCore/Voice/UserAwayTimer.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/UserAwayTimer.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Session { /// The one-shot countdown after which a counts the caller as away. /// The clock the countdown runs on. diff --git a/src/AgentCore.AspNetCore/Voice/UserState.cs b/src/AgentCore.AspNetCore/Voice/Session/UserState.cs similarity index 89% rename from src/AgentCore.AspNetCore/Voice/UserState.cs rename to src/AgentCore.AspNetCore/Voice/Session/UserState.cs index b2e25629..ff0405e3 100644 --- a/src/AgentCore.AspNetCore/Voice/UserState.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/UserState.cs @@ -2,7 +2,7 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Session { /// What the caller is doing right now. internal enum UserState @@ -13,7 +13,7 @@ internal enum UserState /// Not talking. Listening, - /// Silent long enough that userAway fires (wired in step 6). + /// Silent long enough that userAway fires. Away, } } diff --git a/src/AgentCore.AspNetCore/Voice/UserStateChanged.cs b/src/AgentCore.AspNetCore/Voice/Session/UserStateChanged.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/UserStateChanged.cs rename to src/AgentCore.AspNetCore/Voice/Session/UserStateChanged.cs index 71aec76d..55ee168c 100644 --- a/src/AgentCore.AspNetCore/Voice/UserStateChanged.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/UserStateChanged.cs @@ -3,7 +3,7 @@ // 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 +namespace AgentCore.AspNetCore.Voice.Session { /// Raised each time 's user state actually changes. /// The state before the change. diff --git a/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs b/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs new file mode 100644 index 00000000..2470f4db --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs @@ -0,0 +1,45 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Voice.Session +{ + /// Raises for every voice state change of a session, on the hooks of the conversation it speaks for. + internal static class VoiceNotices + { + /// The hooks of the conversation behind a port, or for a port that is not a session. + internal static SessionHooks? HooksOf(IConversationPort port) => (port as ConversationSession)?.Hooks; + + /// Subscribes for the life of the session. is read per change, so a rebound port is followed. + internal static void Attach(VoiceSession session, Func hooks) + { + session.AgentStateChanged += change => Raise(hooks(), VoiceParty.Agent, Of(change.OldState), Of(change.NewState)); + session.UserStateChanged += change => Raise(hooks(), VoiceParty.User, Of(change.OldState), Of(change.NewState)); + } + + private static void Raise(SessionHooks? hooks, VoiceParty party, VoiceState old, VoiceState now) + { + if (hooks is not null && old != now && hooks.Wants()) + { + _ = hooks.Raise(new VoiceStateChanged(hooks.Scope(turnIndex: null, stage: null), party, old, now)); + } + } + + private static VoiceState Of(AgentState state) => state switch + { + AgentState.Thinking => VoiceState.Thinking, + AgentState.Speaking => VoiceState.Speaking, + AgentState.Initializing or AgentState.Idle or AgentState.Listening => VoiceState.Listening, + _ => VoiceState.Listening, + }; + + private static VoiceState Of(UserState state) => state switch + { + UserState.Speaking => VoiceState.Speaking, + UserState.Away => VoiceState.Away, + UserState.Listening => VoiceState.Listening, + _ => VoiceState.Listening, + }; + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceSession.cs b/src/AgentCore.AspNetCore/Voice/Session/VoiceSession.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/VoiceSession.cs rename to src/AgentCore.AspNetCore/Voice/Session/VoiceSession.cs index d0bcbceb..2af59bc2 100644 --- a/src/AgentCore.AspNetCore/Voice/VoiceSession.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/VoiceSession.cs @@ -9,13 +9,19 @@ // (_user_silence_event.set()), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 // LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; one // activity per conversation, so AgentSession and AgentActivity collapse into one type; no handoff, no -// drain, no realtime branch; what "away" says is ours, not LiveKit's (plan Q2/owner ruling); input-side +// drain, no realtime branch; what "away" says is ours, not LiveKit's; input-side // wiring is UserTurnHandler.cs. using System.Diagnostics.CodeAnalysis; +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Threading; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Session { /// One voice conversation's speech: what the agent says, and when. [SuppressMessage( @@ -44,7 +50,7 @@ internal sealed class VoiceSession /// Where scheduling warnings and speech task faults are reported. /// /// When and what to say once the caller has gone quiet, or to disable the - /// timer (plan Q2/owner ruling). The config/schema surface for this is plan step 8. + /// timer. /// public VoiceSession(IConversationOutputPort output, TimeProvider time, ILogger logger, UserAwayOptions? userAway = null) { @@ -362,8 +368,7 @@ private void OnUserAwayTimer(long arm) UserStateChanged?.Invoke(change); } - // LiveKit only emits user_state_changed and leaves "away" to the app; this is our own - // answer to plan Q2, not a LiveKit behaviour. + // LiveKit only emits user_state_changed and leaves "away" to the app; the prompt is ours. try { _ = Say(_userAwayTimer.Options!.Say); diff --git a/src/AgentCore.AspNetCore/Voice/VoiceSessionClosedException.cs b/src/AgentCore.AspNetCore/Voice/Session/VoiceSessionClosedException.cs similarity index 93% rename from src/AgentCore.AspNetCore/Voice/VoiceSessionClosedException.cs rename to src/AgentCore.AspNetCore/Voice/Session/VoiceSessionClosedException.cs index f212ae54..a05e7ba5 100644 --- a/src/AgentCore.AspNetCore/Voice/VoiceSessionClosedException.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/VoiceSessionClosedException.cs @@ -4,7 +4,7 @@ // Licensed under the Apache License, Version 2.0. Modified: translated to C#; one VoiceSession per // conversation, so the flag belongs to the session rather than to a separate activity object. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Session { /// The session closed, or was already closing, while something waited on it. internal sealed class VoiceSessionClosedException() : Exception("the voice session is closing."); diff --git a/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs b/src/AgentCore.AspNetCore/Voice/Speech/BackgroundSpeeches.cs similarity index 90% rename from src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs rename to src/AgentCore.AspNetCore/Voice/Speech/BackgroundSpeeches.cs index 7c4b0324..a8ef5469 100644 --- a/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/BackgroundSpeeches.cs @@ -3,10 +3,11 @@ // d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Turns; + +namespace AgentCore.AspNetCore.Voice.Speech { - /// 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. + /// The speeches that finished speaking but still wait on their own tools. internal sealed class BackgroundSpeeches { private readonly HashSet _speeches = []; diff --git a/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs index 71cfc851..c3cdd158 100644 --- a/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs @@ -2,16 +2,19 @@ // _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). +// IConversationPort runs the model and the tools, so the steps are read off its one stream. using System.Runtime.CompilerServices; using System.Threading.Channels; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn.Lifecycle; +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// Reads one engine turn and splits it into steps at its tool contents. internal sealed class EngineReplyStream @@ -43,6 +46,18 @@ private EngineReplyStream(IConversationPort port, SpeechHandle speechHandle, Tur /// Gets the conversation the turn runs on. public IConversationPort Port { get; } + /// Gets the index of the engine turn, or while it waits on the turn before it. + public int? TurnIndex + { + get + { + lock (_gate) + { + return _turnIndex; + } + } + } + /// 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; @@ -104,7 +119,6 @@ public bool Cut(TurnCut cut) /// 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) @@ -193,6 +207,7 @@ private async Task RunAsync(string userInput, Task previousRun, CancellationToke lock (_gate) { _turnIndex = run.TurnIndex; + _metrics.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) diff --git a/src/AgentCore.AspNetCore/Voice/PipelineReply.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs similarity index 95% rename from src/AgentCore.AspNetCore/Voice/PipelineReply.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs index aabd6205..331189d2 100644 --- a/src/AgentCore.AspNetCore/Voice/PipelineReply.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs @@ -2,16 +2,21 @@ // (_pipeline_reply_task, _pipeline_reply_task_impl), 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, the tools and every step, so the steps are read off -// its stream (plan 2.1); no audio, no TTS, no chat-context writes, no realtime or handoff branch. +// its stream; no audio, no TTS, no chat-context writes, no realtime or handoff branch. -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Threading; +using AgentCore.AspNetCore.Voice.Turns; + +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// Speaks one engine turn, one step at a time: the heart of a generate_reply. /// The speech this reply plays. /// The session whose scheduler, output and states the reply drives. /// The engine turn, already reading. /// Times this reply. - /// The filler each tool id opens while its call runs (plan 2.4). Empty by default. + /// The filler each tool id opens while its call runs. Empty by default. /// Runs once, inline on the forwarding task, when this reply hands its first text to the output. /// How long a reply a final prompt cut mid-step waits for the transport's report. /// Cancelled once, when the transport goes away and no report can come. @@ -25,7 +30,7 @@ internal sealed class PipelineReply( TimeSpan heardTextWait, CancellationToken transportToken) { - /// What a voice caller hears when a tool waits on an approval the conversation cannot take (plan B11). + /// 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."; diff --git a/src/AgentCore.AspNetCore/Voice/ReplyEvent.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyEvent.cs similarity index 90% rename from src/AgentCore.AspNetCore/Voice/ReplyEvent.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyEvent.cs index 8bd12b57..1e107659 100644 --- a/src/AgentCore.AspNetCore/Voice/ReplyEvent.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyEvent.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// One event of an engine turn, in stream order. /// What happened. diff --git a/src/AgentCore.AspNetCore/Voice/ReplyEventKind.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyEventKind.cs similarity index 86% rename from src/AgentCore.AspNetCore/Voice/ReplyEventKind.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyEventKind.cs index c4ea93c4..53158fb4 100644 --- a/src/AgentCore.AspNetCore/Voice/ReplyEventKind.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyEventKind.cs @@ -1,6 +1,6 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { - /// What one read off the engine stream carries (plan 2.1). + /// What one read off the engine stream carries. internal enum ReplyEventKind { /// Non-empty text of the current step. diff --git a/src/AgentCore.AspNetCore/Voice/ReplyHearing.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs similarity index 59% rename from src/AgentCore.AspNetCore/Voice/ReplyHearing.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs index d2295c0e..791cca9b 100644 --- a/src/AgentCore.AspNetCore/Voice/ReplyHearing.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs @@ -5,17 +5,11 @@ // transport, not an audio sink, reports what played, and the reply's engine turn is cut rather than a chat // context written. -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// What the caller heard of one pipeline reply, and the cut its engine turn takes from it. - /// - /// With nothing forwarded the turn keeps the caller's message only; with text forwarded it keeps what the - /// transport reported the caller heard, or, with no report behind the interruption, the text forwarded, as - /// LiveKit keeps forwarded_text. A final prompt that cut a step mid-way may be followed by the report: - /// the reply waits for it, bounded, and recuts its turn to it. - /// /// The engine turn the cut reaches. /// The clock the wait for the report runs on. /// How long a reply a final prompt cut mid-step waits for the transport's report. @@ -32,10 +26,16 @@ internal sealed class ReplyHearing( private readonly TaskCompletionSource _bargeRecorded = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _spoken = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _bargeWindowClosed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _forwarded = string.Empty; private string _beforeLastSpokenStep = string.Empty; + private string? _cutText; + private (string HeardText, TimeSpan PlayedDuration)? _barge; private volatile bool _forwardedText; @@ -48,9 +48,29 @@ internal sealed class ReplyHearing( private bool _cutSettled; + private int _reportWaitStarted; + /// Gets whether the reply has handed any text to the output yet. public bool HasForwardedText => _forwardedText; + /// Gets what the caller heard of the reply: the text its turn was last cut to, or everything forwarded when nothing cut it. + public string HeardText + { + get + { + lock (_gate) + { + return _cutText ?? _forwarded; + } + } + } + + /// Gets whether the window in which a barge-in report can still cut the reply has closed. + public bool BargeWindowClosed => _bargeWindowClosed.Task.IsCompleted; + + /// Gets the index of the reply's engine turn, or while it has not started. + public int? TurnIndex => stream.TurnIndex; + /// Gets whether a final prompt interrupted the reply and it still takes the transport's report. public bool AwaitsBarge { @@ -83,12 +103,10 @@ public bool RecordBarge(string heardText, TimeSpan playedDuration) { lock (_gate) { - if (_barge is not null || _bargeWaitOver) + if (!TryKeepBargeLocked(heardText, playedDuration)) { return false; } - - _barge = (heardText, playedDuration); } _ = _bargeRecorded.TrySetResult(); @@ -117,12 +135,87 @@ public void AddStep(TextForwardingResult forwarded) } } - /// Cuts the reply's engine turn to what the caller heard, without interrupting its speech. + /// + /// Cuts the reply's engine turn to what the caller heard, without interrupting its speech, and closes its barge + /// window. A report that comes once the window closed, or after the first, changes nothing. + /// /// The text the caller heard, as the transport reported it. /// How much of the reply played, as the transport reported it. - public void CutHeard(string heardText, TimeSpan playedDuration) + /// when the report was kept; when it came too late. + public bool CutHeard(string heardText, TimeSpan playedDuration) { - _ = stream.Cut(Heard(heardText, playedDuration)); + TurnCut cut; + lock (_gate) + { + // Kept with the report, so a window that closes right after it still gives the cut text, not all forwarded. + if (!TryKeepBargeLocked(heardText, playedDuration)) + { + return false; + } + + cut = Heard(heardText, playedDuration); + _cutText = cut.ShownText; + } + + _ = _bargeRecorded.TrySetResult(); + _ = stream.Cut(cut); + CloseBargeWindow(); + return true; + } + + /// + /// Marks the reply's speech done: its text is with the transport, which may still be playing it. An interrupted + /// reply settled its cut before its speech was done, so its barge window closes too. + /// + /// Whether the speech was interrupted. + public void MarkSpoken(bool interrupted) + { + _ = _spoken.TrySetResult(); + if (interrupted) + { + CloseBargeWindow(); + } + } + + /// Closes the window in which a barge-in report can still cut the reply: no report for it can count now. + public void CloseBargeWindow() + { + lock (_gate) + { + _bargeWaitOver = true; + } + + _ = _bargeWindowClosed.TrySetResult(); + } + + /// + /// The caller spoke again. Once the speech is done, the transport's report of what was heard may still follow the + /// final prompt, so the window stays open for the same wait a reply a final prompt cut mid-step gives it. + /// + /// A task that completes once the window closed, or at once when an earlier call already waits. It never faults. + public async Task CloseBargeWindowAfterReportWaitAsync() + { + if (Interlocked.Exchange(ref _reportWaitStarted, 1) == 1) + { + return; + } + + await _spoken.Task.ConfigureAwait(false); + if (_bargeWindowClosed.Task.IsCompleted) + { + return; + } + + await WaitForReportAsync(CancellationToken.None).ConfigureAwait(false); + CloseBargeWindow(); + } + + /// Waits until the speech is done and its barge window closed, then gives what the caller heard. + /// , once nothing can change it; empty when the caller heard nothing. + public async Task HeardWhenSettledAsync() + { + await Task.WhenAll(_spoken.Task, _bargeWindowClosed.Task).ConfigureAwait(false); + return HeardText; } /// Cuts the reply's engine turn to what reached the caller, as its interruption does. @@ -137,6 +230,7 @@ public bool CutInterrupted() cut = !forwardedText ? NothingHeard : _barge is { } heard ? Heard(heard.HeardText, heard.PlayedDuration) : new TurnCut(_forwarded, Played: null); + _cutText = cut.ShownText; } return stream.Cut(cut); @@ -161,11 +255,23 @@ public async Task RecutToReportAsync(CancellationToken cancellationToken) _cutSettled = true; cut = Heard(heard.HeardText, heard.PlayedDuration); + _cutText = cut.ShownText; } stream.Recut(cut); } + private bool TryKeepBargeLocked(string heardText, TimeSpan playedDuration) + { + if (_barge is not null || _bargeWaitOver) + { + return false; + } + + _barge = (heardText, playedDuration); + return true; + } + /// Builds the cut for a transport report, which covers only the step whose text played last. private TurnCut Heard(string heardText, TimeSpan playedDuration) { @@ -189,6 +295,11 @@ private async Task WaitForBargeAsync(CancellationToken cancellationToken) return; } + await WaitForReportAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task WaitForReportAsync(CancellationToken cancellationToken) + { using CancellationTokenSource gone = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, transportToken); try { diff --git a/src/AgentCore.AspNetCore/Voice/SayReply.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/SayReply.cs similarity index 92% rename from src/AgentCore.AspNetCore/Voice/SayReply.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/SayReply.cs index 2c19ac62..aa0ee104 100644 --- a/src/AgentCore.AspNetCore/Voice/SayReply.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/SayReply.cs @@ -2,9 +2,12 @@ // (_tts_task, _tts_task_impl), text-only lines 3144-3158, 3160-3173, 3245-3261, 3274-3278, 3346-3352, // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#; no TTS, no audio -// forwarding, no chat-context write (G4), no metrics (2.5: say/filler report none). +// forwarding, no chat-context write, no metrics (say/filler report none). -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Session; + +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// Speaks fixed text: a say, a filler, or a notice — never a model reply. internal static class SayReply diff --git a/src/AgentCore.AspNetCore/Voice/TextForwarding.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/TextForwarding.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs index c3961f4c..76b1a50e 100644 --- a/src/AgentCore.AspNetCore/Voice/TextForwarding.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs @@ -6,8 +6,10 @@ // each forwarding begins its own reply on the output. using System.Text; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Threading; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// Forwards one generation's text to , honouring interruption. internal static class TextForwarding diff --git a/src/AgentCore.AspNetCore/Voice/TextForwardingResult.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwardingResult.cs similarity index 93% rename from src/AgentCore.AspNetCore/Voice/TextForwardingResult.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwardingResult.cs index 948adadb..bde07be5 100644 --- a/src/AgentCore.AspNetCore/Voice/TextForwardingResult.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwardingResult.cs @@ -3,7 +3,7 @@ // Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. // Modified: translated to C#; every audio field dropped. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// What forwarded. /// Every fragment the source yielded, concatenated in order. diff --git a/src/AgentCore.AspNetCore/Voice/TextPlayback.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/TextPlayback.cs rename to src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs index 01b81aa5..aac2d74e 100644 --- a/src/AgentCore.AspNetCore/Voice/TextPlayback.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs @@ -4,7 +4,7 @@ // Modified: translated to C#; the "full"/"partial"/"skipped" values LiveKit's audio playback can also // reach are dropped, since nothing here plays audio. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech.Replies { /// Whether a forwarded piece of text reached the caller, in full, in part, or not at all. internal enum TextPlayback diff --git a/src/AgentCore.AspNetCore/Voice/SpeechDoneCallbacks.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechDoneCallbacks.cs similarity index 91% rename from src/AgentCore.AspNetCore/Voice/SpeechDoneCallbacks.cs rename to src/AgentCore.AspNetCore/Voice/Speech/SpeechDoneCallbacks.cs index 0925a9ea..8eb4b239 100644 --- a/src/AgentCore.AspNetCore/Voice/SpeechDoneCallbacks.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechDoneCallbacks.cs @@ -2,15 +2,12 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. +using AgentCore.AspNetCore.Voice.Diagnostics; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech { /// The callbacks waiting for one to be done. - /// - /// Every callback runs on the thread pool, never on the thread that marked the speech done, as asyncio's - /// call_soon runs it on a later loop turn. That thread may hold the session lock. - /// /// The speech every callback is handed. /// Where a throwing callback is reported. internal sealed class SpeechDoneCallbacks(SpeechHandle owner, ILogger logger) diff --git a/src/AgentCore.AspNetCore/Voice/SpeechHandle.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechHandle.cs similarity index 98% rename from src/AgentCore.AspNetCore/Voice/SpeechHandle.cs rename to src/AgentCore.AspNetCore/Voice/Speech/SpeechHandle.cs index 14ca4aa9..faa018fb 100644 --- a/src/AgentCore.AspNetCore/Voice/SpeechHandle.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechHandle.cs @@ -2,9 +2,12 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#; audio paths removed. +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Threading; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech { /// /// One speech the agent means to say, a reply or a say: whether it may be interrupted, whether it diff --git a/src/AgentCore.AspNetCore/Voice/SpeechPriority.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechPriority.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/SpeechPriority.cs rename to src/AgentCore.AspNetCore/Voice/Speech/SpeechPriority.cs index 38ebf346..66448c7a 100644 --- a/src/AgentCore.AspNetCore/Voice/SpeechPriority.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechPriority.cs @@ -2,7 +2,7 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech { /// How a speech is ordered against others waiting in the speech queue. internal enum SpeechPriority diff --git a/src/AgentCore.AspNetCore/Voice/SpeechQueue.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechQueue.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/SpeechQueue.cs rename to src/AgentCore.AspNetCore/Voice/Speech/SpeechQueue.cs index e0392392..420eb651 100644 --- a/src/AgentCore.AspNetCore/Voice/SpeechQueue.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechQueue.cs @@ -4,7 +4,7 @@ using System.Diagnostics.CodeAnalysis; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech { /// /// The speeches waiting to play: highest priority first, first queued first within a priority. diff --git a/src/AgentCore.AspNetCore/Voice/SpeechScheduler.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs similarity index 98% rename from src/AgentCore.AspNetCore/Voice/SpeechScheduler.cs rename to src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs index be819c53..9310ae31 100644 --- a/src/AgentCore.AspNetCore/Voice/SpeechScheduler.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs @@ -3,9 +3,12 @@ // Licensed under the Apache License, Version 2.0. Modified: translated to C#; audio paths removed. using System.Diagnostics.CodeAnalysis; +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Threading; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech { /// /// Plays one speech at a time from the speech queue, and interrupts the current, queued and background diff --git a/src/AgentCore.AspNetCore/Voice/SpeechTasks.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechTasks.cs similarity index 98% rename from src/AgentCore.AspNetCore/Voice/SpeechTasks.cs rename to src/AgentCore.AspNetCore/Voice/Speech/SpeechTasks.cs index af2e0a36..91eca0d3 100644 --- a/src/AgentCore.AspNetCore/Voice/SpeechTasks.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechTasks.cs @@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Speech { /// /// The tasks one runs, the token they honour, and the interruption backstop diff --git a/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs b/src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/AsyncEvent.cs rename to src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs index 74a3ba61..4a4cba05 100644 --- a/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs +++ b/src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Threading { /// /// An async gate: waiters block until opens it, and closes it diff --git a/src/AgentCore.AspNetCore/Voice/TaskTeardown.cs b/src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/TaskTeardown.cs rename to src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs index 6deaab14..a7310bb6 100644 --- a/src/AgentCore.AspNetCore/Voice/TaskTeardown.cs +++ b/src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs @@ -3,7 +3,7 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Threading { /// Cancellation and fault-logging helpers a speech task's owner tears it down with. internal static class TaskTeardown diff --git a/src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs b/src/AgentCore.AspNetCore/Voice/Transport/ConnectionTaskObserver.cs similarity index 98% rename from src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs rename to src/AgentCore.AspNetCore/Voice/Transport/ConnectionTaskObserver.cs index 8547b4c6..9f5382fa 100644 --- a/src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs +++ b/src/AgentCore.AspNetCore/Voice/Transport/ConnectionTaskObserver.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Transport { /// Which task an observer is watching, so it logs the right line. internal enum ConnectionTaskKind @@ -9,7 +9,7 @@ internal enum ConnectionTaskKind /// The loop that writes the transport. WriteLoop, - /// The wait for the conversation to end: its words reach store 1, then its session goes. + /// The wait for the conversation to end: its words reach the conversation store, then its session goes. SessionClose, } @@ -60,7 +60,7 @@ public async Task ObserveAsync(Task task, ConnectionTaskKind kind, TimeSpan? tim SafeLog(() => logTimeout(conversationId(), DisplayName(kind))); // Task.WaitAsync removes its own continuation from task once the timeout fires, rather - // than leaving one behind to observe a later fault: a probe confirmed this directly. So + // than leaving one behind to observe a later fault. So // if task faults afterward with nobody else watching, that fault surfaces only as an // UnobservedTaskException raised during a GC — which produces no log line, and, under // the default ThrowUnobservedTaskExceptions setting, does not crash the process either. diff --git a/src/AgentCore.AspNetCore/Voice/JsonWebSocketPump.cs b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs similarity index 95% rename from src/AgentCore.AspNetCore/Voice/JsonWebSocketPump.cs rename to src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs index a9ba22a9..3b04e5a9 100644 --- a/src/AgentCore.AspNetCore/Voice/JsonWebSocketPump.cs +++ b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs @@ -1,7 +1,8 @@ using System.Buffers; using System.Net.WebSockets; +using AgentCore.AspNetCore.Voice.Ports; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Transport { /// /// The read loop of one duplex JSON-over-WebSocket connection. is the send half. @@ -48,8 +49,7 @@ public async Task ReadLoopAsync(FrameParser parse, Func dispatch) // The vendor never reconnects, so a socket with no inbound frame for IdleTimeout is // a conversation that already ended, not a fault. The first receive of every message races - // the deadline rather than being cancelled by one: a probe against a real Kestrel - // WebSocket confirmed that cancelling ReceiveAsync's own token while it is pending + // the deadline rather than being cancelled by one: cancelling ReceiveAsync's own token while it is pending // aborts the socket outright — WebSocketState.Aborted — which then makes the // graceful NormalClosure close below throw instead of reaching the vendor. Racing a // side task leaves the receive itself untouched; the side that loses the race is @@ -192,7 +192,7 @@ private void RefuseFrame(FrameOutcome outcome) "the relay sent a frame this endpoint cannot parse."); } - // Log once for the conversation, not once for the frame. Section 3.1. + // Log once for the conversation, not once for the frame. if (outcome.UnknownType is { } unmodelled) { if (!_loggedUnknownFrame) @@ -203,9 +203,9 @@ private void RefuseFrame(FrameOutcome outcome) } else if (!_loggedRefusedFrameBody) { - // A known type whose body will not bind. Section 7.1 treats a vendor that - // changes a frame exactly as it treats one that adds a frame: the frame is - // refused, and the conversation goes on. + // A known type whose body will not bind. A vendor that changes + // a frame is treated like one that adds a frame: the frame is refused, and the + // conversation goes on. _loggedRefusedFrameBody = true; options.LogRefusedFrameBody(outcome.RefusedType!); } @@ -224,13 +224,13 @@ private static void ObserveAbandonedReceive(Task re _ = receiving.ContinueWith( completed => { - // Marks a fault observed the same way the earlier, buffer-free version did, so it + // Marks a fault observed so it // never becomes an unobserved task exception, whatever receiving finished with. _ = completed.Exception; // clearArray: true. The array still holds the caller's own words, and the pool it // goes back to is shared with Kestrel and with every other read loop in this - // process. D23 makes the audit chain the record of a conversation; a pooled buffer that + // process. A pooled buffer that // hands a transcript to the next renter is not a record, it is a leak. ArrayPool.Shared.Return(buffer, clearArray: true); }, diff --git a/src/AgentCore.AspNetCore/Voice/JsonWebSocketPumpOptions.cs b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPumpOptions.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/JsonWebSocketPumpOptions.cs rename to src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPumpOptions.cs index 2c44e90d..de3ecb62 100644 --- a/src/AgentCore.AspNetCore/Voice/JsonWebSocketPumpOptions.cs +++ b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPumpOptions.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Transport { /// What one pump may do, and for how long. /// The largest inbound message the pump accepts, in bytes. diff --git a/src/AgentCore.AspNetCore/Voice/JsonWebSocketSender.cs b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketSender.cs similarity index 99% rename from src/AgentCore.AspNetCore/Voice/JsonWebSocketSender.cs rename to src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketSender.cs index cf78c203..bccf0215 100644 --- a/src/AgentCore.AspNetCore/Voice/JsonWebSocketSender.cs +++ b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketSender.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Threading.Channels; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Transport { /// /// Every send of one duplex JSON-over-WebSocket connection: the write loop, and the close frame. diff --git a/src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs b/src/AgentCore.AspNetCore/Voice/Turns/ConversationSpeechPairing.cs similarity index 97% rename from src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs rename to src/AgentCore.AspNetCore/Voice/Turns/ConversationSpeechPairing.cs index 5864e89e..28b2fe2d 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/ConversationSpeechPairing.cs @@ -1,7 +1,8 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.Voice.Ports; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Turns { /// /// Refuses a document whose conversation transport and speech vendors cannot coexist. diff --git a/src/AgentCore.AspNetCore/Voice/InterruptionSource.cs b/src/AgentCore.AspNetCore/Voice/Turns/InterruptionSource.cs similarity index 94% rename from src/AgentCore.AspNetCore/Voice/InterruptionSource.cs rename to src/AgentCore.AspNetCore/Voice/Turns/InterruptionSource.cs index 089ccdd9..1951d526 100644 --- a/src/AgentCore.AspNetCore/Voice/InterruptionSource.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/InterruptionSource.cs @@ -2,7 +2,7 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Turns { /// Why a speech was interrupted. The first source recorded against a handle wins. internal enum InterruptionSource diff --git a/src/AgentCore.AspNetCore/Voice/TurnMetrics.cs b/src/AgentCore.AspNetCore/Voice/Turns/TurnMetrics.cs similarity index 67% rename from src/AgentCore.AspNetCore/Voice/TurnMetrics.cs rename to src/AgentCore.AspNetCore/Voice/Turns/TurnMetrics.cs index 441bbbc1..df9e8a99 100644 --- a/src/AgentCore.AspNetCore/Voice/TurnMetrics.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/TurnMetrics.cs @@ -1,21 +1,22 @@ // Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py (perform_llm_inference, // ttft) and livekit-agents/livekit/agents/voice/agent_activity.py (_pipeline_reply_task_impl metrics, interrupt), // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. -// Licensed under the Apache License, Version 2.0. Modified: translated to C#; the four readings go to -// AgentCoreTelemetry under AgentCore's own metric names, and every audio-only reading is dropped. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; the four readings are reported +// as TurnLatency notices under AgentCore's own metric names, and every audio-only reading is dropped. -using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks.Notices; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Turns { - /// Times one pipeline reply at the points LiveKit measures it (plan 2.5). + /// Times one pipeline reply at the points LiveKit measures it. /// The clock every reading is taken on. /// /// The at which the caller's final words arrived, or /// for a reply that answers no caller turn, which then reports no first-speech /// and no reply-end reading. /// - internal sealed class TurnMetrics(TimeProvider time, long? userTurnEndedAt) + /// Where each reading goes, with the engine turn it belongs to when there is one. + internal sealed class TurnMetrics(TimeProvider time, long? userTurnEndedAt, Action report) { private long _stepStartedAt; @@ -27,13 +28,16 @@ internal sealed class TurnMetrics(TimeProvider time, long? userTurnEndedAt) private long? _speechEndedAt; - /// Records how long an audio-activity interruption took to take effect. + /// Gets or sets the engine turn this reply runs, once the engine has started it. + public int? TurnIndex { get; set; } + + /// Reports how long an audio-activity interruption took to take effect. /// The clock was read on. /// When the barge-in arrived. - /// Called once every speech it interrupted is done, as LiveKit's interrupt() future resolves. - public static void RecordBargeIn(TimeProvider time, long detectedAt) + /// Where the reading goes. + public static void RecordBargeIn(TimeProvider time, long detectedAt, Action report) { - AgentCoreTelemetry.RecordBargeInLatency(time.GetElapsedTime(detectedAt)); + report(LatencyMetric.BargeIn, time.GetElapsedTime(detectedAt), null); } /// Marks the start of one model step: the engine call for step 1, the end of the tool round before for any later step. @@ -43,7 +47,7 @@ public void StartStep() _stepContentSeen = false; } - /// Marks the first text or tool call of the current step. Only the first per step records a reading. + /// Marks the first text or tool call of the current step. Only the first per step reports a reading. public void MarkFirstContent() { if (_stepContentSeen) @@ -52,15 +56,15 @@ public void MarkFirstContent() } _stepContentSeen = true; - AgentCoreTelemetry.RecordTimeToFirstToken(time.GetElapsedTime(_stepStartedAt)); + report(LatencyMetric.TimeToFirstToken, time.GetElapsedTime(_stepStartedAt), TurnIndex); } - /// Marks the first text of this speech handed to the output. Only the first call records a reading. + /// Marks the first text of this speech handed to the output. Only the first call reports a reading. public void MarkFirstSpeech() { if (userTurnEndedAt is long anchor && Claim(ref _firstSpeechMarked)) { - AgentCoreTelemetry.RecordTimeToFirstSpeech(time.GetElapsedTime(anchor)); + report(LatencyMetric.TimeToFirstSpeech, time.GetElapsedTime(anchor), TurnIndex); } } @@ -70,12 +74,12 @@ public void MarkSpeechEnded() _speechEndedAt = time.GetTimestamp(); } - /// Records the reply end at the last , for a speech that ended with nothing interrupting it. + /// Reports the reply end at the last , for a speech that ended with nothing interrupting it. public void EndReply() { if (userTurnEndedAt is long anchor && _speechEndedAt is long speechEndedAt && Claim(ref _replyEndMarked)) { - AgentCoreTelemetry.RecordTimeToReplyEnd(time.GetElapsedTime(anchor, speechEndedAt)); + report(LatencyMetric.TimeToReplyEnd, time.GetElapsedTime(anchor, speechEndedAt), TurnIndex); } } diff --git a/src/AgentCore.AspNetCore/Voice/UserTurnHandler.cs b/src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs similarity index 93% rename from src/AgentCore.AspNetCore/Voice/UserTurnHandler.cs rename to src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs index 74071949..8ee102de 100644 --- a/src/AgentCore.AspNetCore/Voice/UserTurnHandler.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs @@ -4,13 +4,16 @@ // (_user_turn_completed_task/_impl), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 // LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; Telnyx owns // VAD/STT/endpointing, so an interim Utterance stands in for on_start_of_speech's state part and a final -// one for on_end_of_speech's, min-words/backchannel gates and the on_user_turn_completed hook are out -// (plan section 3), and "is this reply now outdated" is a sequence number rather than a task-identity +// one for on_end_of_speech's, min-words/backchannel gates and the on_user_turn_completed hook are out, +// and "is this reply now outdated" is a sequence number rather than a task-identity // comparison, since a .NET task cannot see its own enclosing Task from inside its own body. +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Voice +namespace AgentCore.AspNetCore.Voice.Turns { /// Turns a caller's interim and final prompts into session state and, on a final one, a reply. /// The session whose states, scheduler and away timer this drives. @@ -21,7 +24,7 @@ internal sealed class UserTurnHandler(VoiceSession session, VoiceActivity activi private long _latestSequence; - /// An interim prompt arrived: the caller is mid-utterance. Never interrupts anything (plan Q3, B8). + /// An interim prompt arrived: the caller is mid-utterance. Never interrupts anything. public void OnInterimTranscript() { session.SetUserState(UserState.Speaking); diff --git a/src/AgentCore.AspNetCore/Voice/VoiceActivity.cs b/src/AgentCore.AspNetCore/Voice/Turns/VoiceActivity.cs similarity index 74% rename from src/AgentCore.AspNetCore/Voice/VoiceActivity.cs rename to src/AgentCore.AspNetCore/Voice/Turns/VoiceActivity.cs index 114da408..0daa891b 100644 --- a/src/AgentCore.AspNetCore/Voice/VoiceActivity.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/VoiceActivity.cs @@ -4,11 +4,19 @@ // (on_final_transcript, its interrupt only), // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#; no realtime model, no tool -// resolution, no min-words gate, no false-interruption pause; the reply runs as one engine turn (plan 2.1). +// resolution, no min-words gate, no false-interruption pause; the reply runs as one engine turn. +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; - -namespace AgentCore.AspNetCore.Voice +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; + +namespace AgentCore.AspNetCore.Voice.Turns { /// Starts the agent's replies and routes a barge-in to the speech it cuts. internal sealed class VoiceActivity @@ -25,8 +33,14 @@ internal sealed class VoiceActivity private readonly TimeSpan _heardTextWait; + private readonly Action? _latency; + + private readonly Action? _agentLine; + private readonly List _replies = []; + private readonly List _openWindows = []; + private IConversationPort _port; private PipelineReply? _lastHeard; @@ -40,18 +54,25 @@ internal sealed class VoiceActivity /// This agent's override of , or /// to keep the session's. /// - /// The filler each tool id opens while its call runs (plan 2.4), or for none. + /// The filler each tool id opens while its call runs, or for none. /// /// How long a reply a final prompt cut mid-step waits for the transport's report, or /// for . /// + /// Where each latency reading goes, or for a notice on the conversation's hooks. + /// + /// Takes each reply as it starts, in order. What the caller heard of it is known once + /// completes. + /// public VoiceActivity( VoiceSession session, IConversationPort port, CancellationToken engineToken, bool? allowInterruptions = null, IReadOnlyDictionary? fillers = null, - TimeSpan? heardTextWait = null) + TimeSpan? heardTextWait = null, + Action? latency = null, + Action? agentLine = null) { _session = session; _port = port; @@ -59,6 +80,9 @@ public VoiceActivity( _allowInterruptions = allowInterruptions; _fillers = fillers ?? NoFillers; _heardTextWait = heardTextWait ?? VoiceOptions.DefaultHeardTextWait; + _latency = latency; + _agentLine = agentLine; + VoiceNotices.Attach(session, () => VoiceNotices.HooksOf(CurrentPort())); } /// Gets whether a speech this activity starts may be interrupted, unless the call says otherwise. @@ -122,7 +146,6 @@ public SpeechHandle GenerateReply(string userInput, bool? allowInterruptions = n public SpeechHandle? TryGenerateReply(string userInput, bool? allowInterruptions = null, long? userTurnEndedAt = null) { SpeechHandle handle = SpeechHandle.Create(_session.Time, _session.Logger, allowInterruptions ?? AllowInterruptions); - TurnMetrics metrics = new(_session.Time, userTurnEndedAt); // One hold of the session lock from the paused check to the queueing, so a close that pauses // scheduling lands either before the engine turn starts or after the speech is queued. @@ -133,11 +156,14 @@ public SpeechHandle GenerateReply(string userInput, bool? allowInterruptions = n return null; } + TurnMetrics metrics = new(_session.Time, userTurnEndedAt, LatencySink(VoiceNotices.HooksOf(_port))); EngineReplyStream stream = EngineReplyStream.Start( _port, userInput, _engineRun, handle, metrics, _session.Logger, _engineToken); _engineRun = stream.Completion; PipelineReply reply = new(handle, _session, stream, metrics, _fillers, OnFirstText, _heardTextWait, _engineToken); _replies.Add(reply); + _openWindows.Add(reply.Hearing); + _agentLine?.Invoke(reply.Hearing); _ = _session.Scheduler.CreateSpeechTask(reply.RunAsync, handle); @@ -149,6 +175,7 @@ public SpeechHandle GenerateReply(string userInput, bool? allowInterruptions = n _ = _replies.Remove(reply); } + reply.Hearing.MarkSpoken(done.IsInterrupted); _session.OnSpeechDone(); }); @@ -158,18 +185,45 @@ public SpeechHandle GenerateReply(string userInput, bool? allowInterruptions = n return handle; } - /// Interrupts the current speech for a final prompt, before the caller's silence can release a step it holds. + /// + /// Interrupts the current speech for a final prompt, before the caller's silence can release a step it holds, and + /// lets every earlier reply's barge window close once the report that may follow the prompt had its wait. + /// public void InterruptByFinalTranscript() { + List closing; lock (_session.SessionLock) { - if (_session.Scheduler.CurrentSpeech is not { IsInterrupted: false, AllowInterruptions: true } current) + // Kept until closed, so the drain still closes a window that waits for its report. + _ = _openWindows.RemoveAll(hearing => hearing.BargeWindowClosed); + closing = [.. _openWindows]; + + if (_session.Scheduler.CurrentSpeech is { IsInterrupted: false, AllowInterruptions: true } current) { - return; + _replies.Find(reply => reply.SpeechHandle == current)?.Hearing.ExpectBarge(); + _ = current.Interrupt(source: InterruptionSource.AudioActivity); } + } - _replies.Find(reply => reply.SpeechHandle == current)?.Hearing.ExpectBarge(); - _ = current.Interrupt(source: InterruptionSource.AudioActivity); + foreach (ReplyHearing hearing in closing) + { + _ = hearing.CloseBargeWindowAfterReportWaitAsync(); + } + } + + /// Closes every reply's barge window at once: the transport is gone, and no report can come. + public void CloseBargeWindows() + { + List closing; + lock (_session.SessionLock) + { + closing = [.. _openWindows]; + _openWindows.Clear(); + } + + foreach (ReplyHearing hearing in closing) + { + hearing.CloseBargeWindow(); } } @@ -210,7 +264,11 @@ public Task InterruptByAudioActivity(string heardText, TimeSpan playedDuration) // own teardown runs later on another thread, and a turn that finished first would commit unheard words. if (interrupted is null) { - heardEarlier?.Hearing.CutHeard(heardText, playedDuration); + if (heardEarlier is not null && !heardEarlier.Hearing.CutHeard(heardText, playedDuration)) + { + VoiceConversationLog.BargeReportTooLate(_session.Logger); + } + return Task.CompletedTask; } @@ -226,12 +284,32 @@ private Task RecordBargeInWhenDone(SpeechHandle interrupted, long detectedAt) TaskCompletionSource recorded = new(TaskCreationOptions.RunContinuationsAsynchronously); interrupted.AddDoneCallback(done => { - TurnMetrics.RecordBargeIn(_session.Time, detectedAt); + TurnMetrics.RecordBargeIn(_session.Time, detectedAt, LatencySink(VoiceNotices.HooksOf(CurrentPort()))); _ = recorded.TrySetResult(); }); return recorded.Task; } + private IConversationPort CurrentPort() + { + lock (_session.SessionLock) + { + return _port; + } + } + + // A reply keeps the hooks of the conversation it started in, so a rebind does not move its readings. + private Action LatencySink(SessionHooks? hooks) + { + return _latency ?? ((metric, value, turnIndex) => + { + if (hooks is not null && hooks.Wants()) + { + _ = hooks.Raise(new TurnLatency(hooks.Scope(turnIndex, stage: null), metric, value)); + } + }); + } + private void OnFirstText(PipelineReply reply) { lock (_session.SessionLock) diff --git a/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs b/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs new file mode 100644 index 00000000..eac7f559 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs @@ -0,0 +1,409 @@ +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Voice.Diagnostics; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Transport; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice.Turns +{ + /// + /// Feeds one voice conversation's inbound events to the turn-taking classes: user turns, replies, + /// barge-ins, and the away prompt. + /// + /// What every call of the host shares: the sessions, the hooks, the clock. + /// The entry the URL named. Every open and lookup this loop makes names it. + /// The vendor kind, which the call gate and CallStarted name. + /// Where every reply goes. + /// Watches every task this loop starts. + /// The clock the host bound. + /// The logger of the connection that owns this loop. + /// How long a replaced session may take to close. + /// Cancelled once, for any reason the transport is going away. + /// The away prompt and the per-tool filler, or for . + /// + /// Run once another connection's call with this loop's call id took the conversation over, so the transport + /// drops this connection. It runs on that call's admission, so it must not block. + /// + internal sealed class VoiceConversationLoop( + PhoneCallHost calls, + string entry, + string transport, + IConversationOutputPort output, + ConnectionTaskObserver observer, + TimeProvider time, + ILogger logger, + TimeSpan closeTimeout, + CancellationToken connectionToken, + VoiceOptions? options = null, + Action? replaced = null) + { + private static readonly IReadOnlyDictionary NoHeaders = new Dictionary(StringComparer.Ordinal); + + private readonly VoiceOptions _options = options ?? VoiceOptions.Default; + + private readonly VoiceSession _voice = new(output, time, logger, (options ?? VoiceOptions.Default).UserAway); + + private readonly Lock _linesGate = new(); + + private Task _lines = Task.CompletedTask; + + private volatile PhoneCall? _call; + + private volatile PhoneCall? _handingOver; + + private volatile ConversationSession? _session; + + private volatile VoiceActivity? _activity; + + private UserTurnHandler? _userTurns; + + private bool _loggedPromptBeforeSetup; + + private bool _loggedSecondSetup; + + /// Gets the id of the conversation this loop answers, or before it started. + public string? ConversationId => _session?.ConversationId; + + /// Gets whether a conversation has started on this loop. + public bool HasStarted => _session is not null; + + /// + /// Gets whether the loop stopped because the call the transport offered was refused: by the call gate, or because + /// it could not start on its conversation. + /// + public bool WasRefused { get; private set; } + + /// Gets why the call was refused, when is set. + public CallRefusal? Refusal { get; private set; } + + /// Gets whether the call was refused as busy because another call holds its conversation. + internal bool HeldByCall { get; private set; } + + internal PhoneCall? Call => _call; + + /// Gets whether another connection's call with this loop's call id took the conversation over. + internal bool WasReplaced => _call?.WasReplaced == true; + + /// Reads every inbound event, in order, until the stream ends or a call is refused. + /// The conversation's inbound events. + /// A task that completes when the stream ends, or once is set. + public async Task RunAsync(IAsyncEnumerable inputs) + { + await foreach (ConversationInput input in inputs.ConfigureAwait(false)) + { + switch (input) + { + case ConversationInput.Started started: + if (!await StartConversationAsync(started).ConfigureAwait(false)) + { + return; + } + + break; + + case ConversationInput.Utterance { IsFinal: true } utterance: + await OnFinalUtteranceAsync(utterance.Text).ConfigureAwait(false); + break; + + case ConversationInput.Utterance { Text.Length: > 0 }: + _userTurns?.OnInterimTranscript(); + break; + + case ConversationInput.Barge barge: + Interrupt(barge); + break; + + default: + // A keypress, or an empty interim transcript. Neither is part of turn-taking. + break; + } + } + } + + /// + /// Tears the conversation's speech down: LiveKit's session close order, then the engine turns behind it, then + /// the spoken lines still queued, each with what the caller heard by now. + /// + /// + /// A task that completes once no speech is left running, every engine turn has ended, and every line was raised + /// or its wait passed the close timeout. + /// + public async Task DrainAsync() + { + await _voice.CloseAsync().ConfigureAwait(false); + + if (_activity is { } activity) + { + await activity.EngineRun.ConfigureAwait(false); + } + + _activity?.CloseBargeWindows(); + Task lines; + lock (_linesGate) + { + lines = _lines; + } + + await observer.ObserveAsync(lines, ConnectionTaskKind.SessionClose, closeTimeout).ConfigureAwait(false); + } + + /// Ends the call, if one started, and closes its session. + /// Why the conversation ended. + /// The transport's word for why, or . + /// A task that completes once the session is closed or its close timed out. + public async Task EndAsync(ConversationEndReason reason, string? cause = null) + { + if (_call is not { } call) + { + return; + } + + await call.EndAsync(reason, cause).ConfigureAwait(false); + await observer.ObserveAsync(call.CloseAsync().AsTask(), ConnectionTaskKind.SessionClose, closeTimeout).ConfigureAwait(false); + } + + /// when the call was refused, so this loop holds no conversation. + private async Task StartConversationAsync(ConversationInput.Started started) + { + PhoneCall? previous = _call; + if (previous is not null) + { + if (!_loggedSecondSetup) + { + _loggedSecondSetup = true; + VoiceConversationLog.SecondSetupFrame(logger, previous.ConversationId); + } + + // The same call id: the new admission takes the call over and the conversation goes on, as a newer + // connection would. Another id: the previous call's session closes with no end. + if (previous.CallId == started.ConversationId) + { + _handingOver = previous; + } + else + { + await CloseReplacedAsync(previous).ConfigureAwait(false); + previous = null; + } + } + + CallOffer offer = new(entry, started.ConversationId, started.From, started.To, started.Headers ?? NoHeaders, transport); + PhoneCallAdmission admission = await PhoneCall.AdmitAsync(calls, offer, connectionToken, OnCallReplaced).ConfigureAwait(false); + if (admission.Call is not { } call) + { + if (previous is not null) + { + await CloseReplacedAsync(previous).ConfigureAwait(false); + } + + Refuse(admission.Refusal, admission.HeldByCall); + return false; + } + + // Every admitted call reaches StartAsync or AbandonAsync, whatever the start does: one that reaches + // neither keeps its conversation busy. + bool callStarted = false; + try + { + await call.StartAsync(connectionToken).ConfigureAwait(false); + callStarted = true; + } + catch (CallConversationLostException) + { + CallLog.ConversationHeldByCall(calls.Logger, call.CallId, call.ConversationId); + Refuse(CallRefusal.Busy, heldByCall: true); + return false; + } + catch (InvalidOperationException) when (call.HasEnded) + { + Refuse(CallRefusal.Unavailable, heldByCall: false); + return false; + } + finally + { + if (!callStarted) + { + await call.AbandonAsync().ConfigureAwait(false); + if (previous is not null) + { + await CloseReplacedAsync(previous).ConfigureAwait(false); + } + } + } + + _call = call; + ConversationSession session = call.Session; + _session = session; + + // Taken over, the previous call closes nothing; one the gate moved to another conversation closes its own. + if (previous is not null) + { + await observer.ObserveAsync(previous.CloseAsync().AsTask(), ConnectionTaskKind.SessionClose, closeTimeout).ConfigureAwait(false); + } + + if (_activity is { } activity) + { + activity.Rebind(session); + return true; + } + + VoiceActivity begun = new( + _voice, + session, + connectionToken, + fillers: _options.Fillers, + heardTextWait: _options.HeardTextWait, + agentLine: HoldAgentLine); + _userTurns = new UserTurnHandler(_voice, begun); + _activity = begun; + _voice.Start(); + return true; + } + + // Closed already: a refused admission must leave EndAsync nothing of this loop's own to end. + private async Task CloseReplacedAsync(PhoneCall previous) + { + await observer.ObserveAsync(previous.CloseAsync().AsTask(), ConnectionTaskKind.SessionClose, closeTimeout).ConfigureAwait(false); + _call = null; + _session = null; + } + + // A call this loop's own setup frame took over leaves the connection up. + private void OnCallReplaced(PhoneCall call) + { + if (!ReferenceEquals(call, _handingOver)) + { + replaced?.Invoke(); + } + } + + private void Refuse(CallRefusal? refusal, bool heldByCall) + { + WasRefused = true; + Refusal = refusal; + HeldByCall = heldByCall; + } + + private async Task OnFinalUtteranceAsync(string text) + { + if (_userTurns is not { } userTurns) + { + // Log once for the conversation, not once for the utterance. + if (!_loggedPromptBeforeSetup) + { + _loggedPromptBeforeSetup = true; + VoiceConversationLog.PromptBeforeSetup(logger); + } + + return; + } + + await KeepSessionAliveAsync().ConfigureAwait(false); + + HearCaller(text); + userTurns.OnFinalTranscript(text); + } + + // Every line, caller or agent, is a link of one chain, raised only once the link before it was: LineSpoken + // order is speech order. An agent link holds the chain until the caller can hear no more of its reply, + // so the caller's next words wait behind it. + private void HearCaller(string text) + { + if (_call is not { } call) + { + return; + } + + lock (_linesGate) + { + ChainLocked(() => call.HeardAsync(Speaker.Caller, text).AsTask()); + } + } + + private void HoldAgentLine(ReplyHearing hearing) + { + if (_call is not { } call) + { + return; + } + + lock (_linesGate) + { + ChainLocked(() => RaiseAgentLineAsync(call, hearing)); + } + } + + private static async Task RaiseAgentLineAsync(PhoneCall call, ReplyHearing hearing) + { + string heard = await hearing.HeardWhenSettledAsync().ConfigureAwait(false); + if (heard.Length > 0) + { + // The reply's own turn: a barge-in may have started a newer turn since. + await call.HeardAsync(Speaker.Agent, heard, turnIndex: hearing.TurnIndex).ConfigureAwait(false); + } + } + + // Queued, never inline, so no store lookup runs under the lock; a link follows the one before it however that + // one ended. The window close that releases an agent link never waits on the chain, so the chain cannot lock up. + private void ChainLocked(Func raise) + { + Task line = _lines = _lines + .ContinueWith(_ => raise(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default) + .Unwrap(); + + // ReadLoop: no task kind names a line, and PhoneCall already catches and logs a line's own store fault. + _ = observer.ObserveAsync(line, ConnectionTaskKind.ReadLoop); + } + + /// Tells the store this conversation is still being had. + /// A task that completes once the store has been read. + private async Task KeepSessionAliveAsync() + { + if (_session is not { } session) + { + return; + } + + try + { + _ = await calls.Sessions.TryGetAsync(entry, session.ConversationId, connectionToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (connectionToken.IsCancellationRequested) + { + // The caller hung up after speaking: the touch is moot, but the words are still a line. + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted( + logger, session.ConversationId, fault)); + } + } + + private void Interrupt(ConversationInput.Barge barge) + { + if (_activity is not { } activity || _session is not { } session) + { + return; + } + + // Stopped first: the interrupted speech still flushes its step as it tears down, and that closing + // token must meet a reply already stopped rather than reach the transport after the interrupt. + _ = observer.ObserveAsync(output.StopAsync(connectionToken).AsTask(), ConnectionTaskKind.WriteLoop); + + // The latency is recorded once every speech it interrupted is done; nothing here waits for that. + _ = activity.InterruptByAudioActivity(barge.HeardText, barge.PlayedDuration); + + VoiceConversationLog.InterruptReceived(logger, session.ConversationId); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs b/src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs deleted file mode 100644 index fedfeac6..00000000 --- a/src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs +++ /dev/null @@ -1,284 +0,0 @@ -using AgentCore.Application.Conversation; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Microsoft.Extensions.Logging; - -namespace AgentCore.AspNetCore.Voice -{ - /// - /// Feeds one voice conversation's inbound events to the turn-taking classes: user turns, replies, - /// barge-ins, and the away prompt. - /// - /// The one session owner, shared by every entry. - /// The entry the URL named. Every open and lookup this loop makes names it. - /// Where every reply goes. - /// Watches every task this loop starts. - /// The clock the host bound. - /// The logger of the connection that owns this loop. - /// How long a replaced session may take to close. - /// Cancelled once, for any reason the transport is going away. - /// The away prompt and the per-tool filler, or for . - internal sealed class VoiceConversationLoop( - IConversationSessions sessions, - string entry, - IConversationOutputPort output, - ConnectionTaskObserver observer, - TimeProvider time, - ILogger logger, - TimeSpan closeTimeout, - CancellationToken connectionToken, - VoiceOptions? options = null) - { - private readonly VoiceOptions _options = options ?? VoiceOptions.Default; - - private readonly VoiceSession _voice = new(output, time, logger, (options ?? VoiceOptions.Default).UserAway); - - private volatile ConversationSession? _session; - - private volatile VoiceActivity? _activity; - - private UserTurnHandler? _userTurns; - - private bool _loggedPromptBeforeSetup; - - private bool _loggedSecondSetup; - - /// Gets the id of the conversation this loop answers, or before it started. - public string? ConversationId => _session?.ConversationId; - - /// Gets whether a conversation has started on this loop. - public bool HasStarted => _session is not null; - - /// - /// Gets whether the loop stopped because the conversation the transport named is held live by another entry. - /// - public bool WasRefused { get; private set; } - - /// Reads every inbound event, in order, until the stream ends or an open is refused. - /// The conversation's inbound events. - /// A task that completes when the stream ends, or once is set. - public async Task RunAsync(IAsyncEnumerable inputs) - { - await foreach (ConversationInput input in inputs.ConfigureAwait(false)) - { - switch (input) - { - case ConversationInput.Started started: - if (!await StartConversationAsync(started.ConversationId).ConfigureAwait(false)) - { - return; - } - - break; - - case ConversationInput.Utterance { IsFinal: true } utterance: - await OnFinalUtteranceAsync(utterance.Text).ConfigureAwait(false); - break; - - case ConversationInput.Utterance { Text.Length: > 0 }: - _userTurns?.OnInterimTranscript(); - break; - - case ConversationInput.Barge barge: - Interrupt(barge); - break; - - default: - // A keypress, or an empty interim transcript. Neither is part of turn-taking. - break; - } - } - } - - /// Tears the conversation's speech down: LiveKit's session close order, then the engine turns behind it. - /// A task that completes once no speech is left running and every engine turn has ended. - public async Task DrainAsync() - { - await _voice.CloseAsync().ConfigureAwait(false); - - if (_activity is { } activity) - { - await activity.EngineRun.ConfigureAwait(false); - } - } - - /// Closes the audit chain and the session of the conversation, if one started. - /// Why the conversation ended. - /// A task that completes once the session is closed or its close timed out. - public async Task EndAsync(ConversationEndReason reason) - { - if (_session is not { } session) - { - return; - } - - // The id may have moved on: this loop's own session unloaded, and a later caller reopened it. - // Ending or closing that newer session here would answer this loop's hang-up with someone else's - // call. A store that cannot be read falls back to today's behaviour, on this loop's own session. - ConversationSession? current; - try - { - current = await sessions.TryGetAsync(entry, session.ConversationId, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted(logger, session.ConversationId, fault)); - current = session; - } - - if (current is not null && !ReferenceEquals(current, session)) - { - return; - } - - try - { - _ = session.EndConversation(reason); - } - catch (Exception fault) - { - ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.ConversationEndFaulted( - logger, - session.ConversationId, - fault)); - } - - await observer - .ObserveAsync(CloseSessionAsync(session.ConversationId), ConnectionTaskKind.SessionClose, closeTimeout) - .ConfigureAwait(false); - } - - private async Task CloseSessionAsync(string conversationId) - { - await sessions.CloseAsync(entry, conversationId, CancellationToken.None).ConfigureAwait(false); - } - - /// when another entry holds the conversation live, so nothing was opened. - private async Task StartConversationAsync(string conversationId) - { - if (_session is { } replaced) - { - if (!_loggedSecondSetup) - { - _loggedSecondSetup = true; - VoiceConversationLog.SecondSetupFrame(logger, replaced.ConversationId); - } - - // The id may have moved on: replaced already unloaded, and a later caller reopened it. Closing - // that newer session here would tear down a call this loop never held. A store that cannot be - // read falls back to today's behaviour, on this loop's own replaced session. - ConversationSession? current; - try - { - current = await sessions.TryGetAsync(entry, replaced.ConversationId, connectionToken).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted(logger, replaced.ConversationId, fault)); - current = replaced; - } - - if (current is null || ReferenceEquals(current, replaced)) - { - await observer - .ObserveAsync( - CloseSessionAsync(replaced.ConversationId), - ConnectionTaskKind.SessionClose, - closeTimeout) - .ConfigureAwait(false); - } - - // Closed already: a refused reopen below must leave EndAsync nothing of this loop's own to end. - _session = null; - } - - ConversationSession session; - try - { - session = await sessions.GetOrOpenAsync(entry, conversationId, state: null, connectionToken).ConfigureAwait(false); - } - catch (ConversationInUseException) - { - WasRefused = true; - VoiceConversationLog.ConversationInUse(logger, conversationId, entry); - return false; - } - - _session = session; - - if (_activity is { } activity) - { - activity.Rebind(session); - return true; - } - - VoiceActivity started = new( - _voice, - session, - connectionToken, - fillers: _options.Fillers, - heardTextWait: _options.HeardTextWait); - _userTurns = new UserTurnHandler(_voice, started); - _activity = started; - _voice.Start(); - return true; - } - - private async Task OnFinalUtteranceAsync(string text) - { - if (_userTurns is not { } userTurns) - { - // Log once for the conversation, not once for the utterance. - if (!_loggedPromptBeforeSetup) - { - _loggedPromptBeforeSetup = true; - VoiceConversationLog.PromptBeforeSetup(logger); - } - - return; - } - - await KeepSessionAliveAsync().ConfigureAwait(false); - - userTurns.OnFinalTranscript(text); - } - - /// Tells the store this conversation is still being had. - /// A task that completes once the store has been read. - private async Task KeepSessionAliveAsync() - { - if (_session is not { } session) - { - return; - } - - try - { - _ = await sessions.TryGetAsync(entry, session.ConversationId, connectionToken).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted( - logger, session.ConversationId, fault)); - } - } - - private void Interrupt(ConversationInput.Barge barge) - { - if (_activity is not { } activity || _session is not { } session) - { - return; - } - - // Stopped first: the interrupted speech still flushes its step as it tears down, and that closing - // token must meet a reply already stopped rather than reach the transport after the interrupt. - _ = observer.ObserveAsync(output.StopAsync(connectionToken).AsTask(), ConnectionTaskKind.WriteLoop); - - // The latency is recorded once every speech it interrupted is done; nothing here waits for that. - _ = activity.InterruptByAudioActivity(barge.HeardText, barge.PlayedDuration); - - VoiceConversationLog.InterruptReceived(logger, session.ConversationId); - } - } -} diff --git a/src/AgentCore.Domain/Audit/AuditEventVocabulary.cs b/src/AgentCore.Domain/Audit/AuditEventVocabulary.cs index ae672a94..8f714297 100644 --- a/src/AgentCore.Domain/Audit/AuditEventVocabulary.cs +++ b/src/AgentCore.Domain/Audit/AuditEventVocabulary.cs @@ -38,7 +38,7 @@ public static void Validate(AuditEvent auditEvent) { // An empty value here is the one thing that cannot be true: every text hashes to 64 // characters, the empty string included, so an empty hash proves nothing and would leave - // the row unverifiable against store 1 forever. + // the row unverifiable against the message store forever. RequireHash(AuditPayloadKeys.ReplyTextSha256, replyText, nameof(auditEvent)); } @@ -78,7 +78,7 @@ private static void RequireReplyInterrupted(AuditEvent auditEvent) } // Section 11, item 6a: the event proves the text the caller ACTUALLY HEARD. The words - // are in store 1, where they stay erasable, so what is required here is the digest. + // are in the message store, where they stay erasable, so what is required here is the digest. if (!auditEvent.Payload.TryGetValue(AuditPayloadKeys.UtteranceUntilInterruptSha256, out string? utterance)) { throw new ArgumentException( diff --git a/src/AgentCore.Domain/Audit/AuditHash.cs b/src/AgentCore.Domain/Audit/AuditHash.cs index bef56e41..531d9975 100644 --- a/src/AgentCore.Domain/Audit/AuditHash.cs +++ b/src/AgentCore.Domain/Audit/AuditHash.cs @@ -77,7 +77,7 @@ public static AuditHash FromDigest(ReadOnlySpan digest) /// The text to hash. The empty string is legal and hashes to a full digest. /// The SHA-256 of the UTF-8 bytes of the text. /// - /// The spoken words of a conversation live in store 1, where they stay erasable, and the chain stores + /// The spoken words of a conversation live in the message store, where they stay erasable, and the chain stores /// this instead. The bytes are UTF-8 with no byte-order mark, which is the same input /// encode(sha256(convert_to(t, 'UTF8')), 'hex') reads in PostgreSQL, so a reviewer can /// recompute the proof in either place. diff --git a/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs b/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs index a7e5c88c..b6c93fca 100644 --- a/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs +++ b/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs @@ -77,8 +77,9 @@ public static class AuditPayloadKeys /// /// On turn.refused: why the turn was refused. busy: another turn held the conversation past the /// wait limit. conflict: the store refused the words, because another session saved that turn first. - /// gone: the client left while the turn waited to start. running: this session was still running - /// a turn. dropped: the caller let go of the turn before it read the reply. A busy or + /// gone: the client left while the turn waited to start. dropped: the caller let go of the turn + /// before it read the reply. terminal: the conversation had reached a terminal stage. disposed: the + /// session was disposed. in_use: another entry holds the conversation. A busy or /// gone refusal carries no turn index: the turn never read the conversation, so the index it would /// have taken is not known. /// diff --git a/src/AgentCore.Domain/Audit/ConversationEndReason.cs b/src/AgentCore.Domain/Audit/ConversationEndReason.cs index 381ffa97..53e243b2 100644 --- a/src/AgentCore.Domain/Audit/ConversationEndReason.cs +++ b/src/AgentCore.Domain/Audit/ConversationEndReason.cs @@ -3,63 +3,18 @@ namespace AgentCore.Domain.Audit /// /// The closed set of ways one conversation ends. /// - /// - /// - /// The set is closed for the reason the chain exists. D23 makes the audit table the record of the - /// conversation, and D26 buys 14 days of free retention on every other signal, so §9 calls this chain the - /// only long-term record. A report written years later reads this table and nothing else. A - /// free-text reason cannot be counted: one path writes - /// hangup, a second writes caller hung up, and a third writes HANGUP, and the - /// three are one fact under three names. - /// - /// - /// Section 4 names every way a conversation ends, and there are four. The caller hangs up, and only the - /// vendor adapter sees it. The stage machine reaches a terminal stage, and the agent finished the - /// work. The agent hands the conversation to a human through the conference pattern of section 4.3, which is - /// item 5 of section 11. Or a fault ends the conversation. - /// - /// - /// A caller that holds more detail puts it in another payload key. The vendor hang-up cause, the - /// terminal stage under , and the message of a fault are all - /// facts beside the reason, and none of them is the reason. The reason stays countable. - /// - /// - /// The set is small on purpose, and it stays small. A new reason is a change to this enum, a change - /// to , and a new row in the public API file. - /// - /// public enum ConversationEndReason { /// The caller hung up. - /// - /// Only the telephony adapter sees this, because the hang-up arrives on a Conversation Control webhook - /// and never on the relay socket. The vendor reports a cause with it, and that cause is detail - /// beside the reason rather than the reason. - /// CallerHungUp = 0, /// The stage machine reached a terminal stage, and the agent finished the conversation. - /// - /// The turn loop closes the chain itself here, because it is the only place that knows the stage - /// the machine reached. The event carries that stage under - /// , so a reader still learns which ending it was. - /// AgentCompleted = 1, /// The conversation went to a human through the conference pattern. - /// - /// Section 11, item 5, and section 4.3: the adapter joins the conversation to a conference and never - /// sends the Telnyx transfer command, which costs $0.10 for each invocation (T27). The - /// conversation leaves the agent at this point, so this is the last event of its chain. - /// TransferredToHuman = 2, /// A fault ended the conversation. - /// - /// This is the ending section 8.7 works to prevent, so it is rare. A tool that fails and a run - /// that answers with no text both speak the fallback line and keep the conversation, and neither reaches - /// here. A dropped relay socket and a stage that names no agent do. - /// Faulted = 3, } } diff --git a/src/AgentCore.Domain/TurnResult.cs b/src/AgentCore.Domain/TurnResult.cs index 394e6c79..06852570 100644 --- a/src/AgentCore.Domain/TurnResult.cs +++ b/src/AgentCore.Domain/TurnResult.cs @@ -3,28 +3,6 @@ namespace AgentCore.Domain /// /// What one finished turn did to one conversation. /// - /// - /// - /// The turn loop produces one of these for each turn, and the host reads it. It is a pure record and - /// it holds no live object, so it crosses a process boundary and a log line unchanged. - /// - /// - /// A failed extraction is reported here and never thrown. The extractor has no retry, so a turn that - /// filled no slot still ends, the machine still picks a stage, and the caller still hears the reply. - /// - /// - /// A failed turn is reported the same way. Section 8.7 gives the turn loop two failures it must - /// survive: a run that returns an empty reply after 40 tool rounds, and a tool that fails four times - /// in a row. Both end the turn with a spoken fallback, both fill , and neither - /// drops the conversation. - /// - /// - /// is always the text the caller heard. It is the fallback when the turn - /// failed, and it is utteranceUntilInterrupt when the caller spoke over the reply. Nothing - /// here is estimated: section 7.1 says the relay reports both truncation values, so the audit row of - /// item 6a reads them and never computes them. - /// - /// /// The id of the conversation this turn belongs to. /// The zero-based index of the turn that just ran. /// @@ -39,13 +17,16 @@ namespace AgentCore.Domain /// is set, and the truncated reply when /// is set. /// - /// Whether the stage after the turn ends the conversation. + /// + /// Whether the conversation is over after this turn: the stage after it is terminal, or a host or a tool + /// ended the conversation while it ran. + /// /// /// The reason the extractor produced nothing, or when it ran or did not run. /// /// /// The reason the turn spoke the fallback instead of a reply, or when the turn - /// answered. It names the row of section 8.7 that the turn met. + /// answered. /// /// /// when the reply was not cut. A value when it was: how long the caller heard @@ -54,7 +35,7 @@ namespace AgentCore.Domain /// reply.interrupted event then carries no durationUntilInterruptMs. /// /// - /// The moment the turn ended, read from the clock the turn loop was given. The audit chain of D23 + /// The moment the turn ended, read from the clock the turn loop was given. The audit chain /// stamps occurred_at from it. The sink runs behind a bounded Channel and a background /// writer, so a writer that read its own clock would stamp the event one enqueue late and a slow /// queue would move the record of the conversation. The turn loop reads the clock once, when the turn ends, diff --git a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs index ba76ef86..07d03220 100644 --- a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Vendors.OpenAiLive; using AgentCore.AspNetCore.Vendors.TelnyxRelay; using AgentCore.Hosting.Secrets; using AgentCore.Infrastructure.Audit.Postgres; @@ -79,11 +80,6 @@ public static WebApplicationBuilder AddAgentCoreHost( /// The options registered. /// The one outbound pipeline every adapter shares. /// The host's own logging, for the adapters that report what they are doing. - /// - /// The host's own configure callback is registered after this one, and configure - /// callbacks run in registration order — so the host still has the last word, which it has to - /// have: every Use* seam is a setter rather than a list, so whoever writes last wins. - /// private static void Configure( IConfiguration hostConfiguration, AgentCoreOptions options, @@ -103,27 +99,23 @@ private static void Configure( // Every vendor's models.dev lookup shares one fetch of the catalog. _ = options.UseModelCatalog(ModelsDevCatalogPort.CreateFromConfiguration(httpClients, hostConfiguration)); - // providers.llm[].kind picks the adapter for each entry. _ = options.UseChatClients( new OpenAiChatClientAdapter(), OpenCodeGoChatClientAdapter.CreateFromConfiguration(httpClients, hostConfiguration)); - // providers.embeddings.kind picks the adapter. _ = options.UseEmbeddings(new OpenAiEmbeddingGeneratorAdapter()); - // providers.knowledge.kind picks the adapter. _ = options.UseKnowledgeStores(new QdrantKnowledgeAdapter()); - // providers.moderation.kind picks the adapter. _ = options.UseModeration(new OpenAiModerationAdapter(httpClients)); - // providers.telemetry.kind picks the adapter. _ = options.UseTelemetry(new GrafanaOtlpTelemetryAdapter()); - // providers.conversation.kind picks one. - _ = options.UseConversation(new TelnyxRelayConversationAdapter()); + _ = options.UseConversation( + new TelnyxRelayConversationAdapter(), + // The host's own callback runs after this one and may set another resolver. + new OpenAiLiveConversationAdapter(httpClients, () => options.SecretResolver)); - // The speech vendor. _ = options.UseSpeech(new TelnyxRelaySpeechAdapter()); // kind: http. Every header resolved at startup, so no tool call costs a lookup. @@ -134,13 +126,10 @@ private static void Configure( _ = options.AddToolSource(startup => new McpToolSource( startup.Secrets, () => McpHttpClient(httpClients), loggers)); - // providers.audit.kind picks the adapter. _ = options.UseAuditSinks(new PostgresAuditSinkAdapter()); - // providers.conversations.kind picks the adapter. _ = options.UseConversationStores(new PostgresConversationStoreAdapter()); - // providers.blobs.kind picks the adapter. _ = options.UseBlobStores(new S3BlobStoreAdapter()); } diff --git a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs index 089333ce..579eb93f 100644 --- a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs @@ -1,5 +1,5 @@ -using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Voice.Routing; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -17,12 +17,12 @@ public static class AgentCoreHostEndpointExtensions /// The application to map on. /// /// The route the OpenAI-compatible Responses endpoint answers on, with {entry} naming - /// the entry unless the host attaches an entry selector, or for + /// the entry unless a hook's BeforeEntryAsync picks it, or for /// . /// /// - /// The route the conversation socket answers on, with {entry} naming the entry unless the - /// host attaches an entry selector, or + /// The route the conversation socket answers on, with {entry} naming the entry unless a hook's + /// BeforeEntryAsync picks it, or /// for . /// /// The mapped routes, so a host adds its own conventions to each. diff --git a/src/AgentCore.Infrastructure/Audit/Postgres/PostgresAuditSink.cs b/src/AgentCore.Infrastructure/Audit/Postgres/PostgresAuditSink.cs index 436fcba4..8f0c5add 100644 --- a/src/AgentCore.Infrastructure/Audit/Postgres/PostgresAuditSink.cs +++ b/src/AgentCore.Infrastructure/Audit/Postgres/PostgresAuditSink.cs @@ -10,7 +10,7 @@ namespace AgentCore.Infrastructure.Audit.Postgres { /// - /// Store 3, in PostgreSQL. It appends and it never updates. + /// The audit store, in PostgreSQL. It appends and it never updates. /// internal sealed class PostgresAuditSink : IAuditSinkPort, IAsyncDisposable { diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs index 8dfce6b6..e3266b8d 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs @@ -39,7 +39,7 @@ public async ValueTask CreateAsync(string conversationId, Ca } return await GetAsync(conversationId, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException($"Store 0 lost conversation '{conversationId}' between its write and its read."); + ?? throw new InvalidOperationException($"The conversation store lost conversation '{conversationId}' between its write and its read."); } /// @@ -270,6 +270,22 @@ public ValueTask> AppendAsync( return _words.AppendAsync(conversationId, messages, state, cancellationToken); } + /// + public ValueTask SaveStateAsync( + string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(conversationId); + ArgumentNullException.ThrowIfNull(state); + + NpgsqlParameter parameter = new() + { + NpgsqlDbType = NpgsqlDbType.Jsonb, + Value = JsonSerializer.Serialize(state, ConversationStateJson.Options), + }; + + return AmendAsync(SaveStateSql, conversationId, parameter, cancellationToken); + } + /// public ValueTask RewriteAsync( string conversationId, string messageId, ChatMessage content, CancellationToken cancellationToken = default) diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs index b224acf3..0a0e9d6a 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs @@ -52,6 +52,13 @@ SELECT max(updated_at) AS last_message_at FROM {Schema}.conversation_message x W AND {NextTurnIndex} <= ($2::jsonb ->> 'nextTurnIndex')::int """; + /// Writes a state no words carry. As with , a state behind the stored one is never written. + internal const string SaveStateSql = + $""" + UPDATE {Schema}.conversation SET state = $2, updated_at = now() + WHERE conversation_id = $1 AND {NextTurnIndex} <= ($2::jsonb ->> 'nextTurnIndex')::int + """; + /// Reads the turn a conversation takes next, to tell a refused append from a missing conversation. internal const string NextTurnIndexSql = $"SELECT {NextTurnIndex} FROM {Schema}.conversation WHERE conversation_id = $1"; @@ -210,7 +217,8 @@ ON CONFLICT (store_id) DO NOTHING $"DELETE FROM {Schema}.response_continuation WHERE store_id = $1"; /// - /// Reads what store 1 holds for each spoken turn of one conversation, beside what store 3 proves. + /// Reads what the conversation store holds for each spoken turn of one conversation, beside what the audit chain proves. A line a phone + /// vendor's front voice spoke (author front_voice, Application's FrontVoice) is not the agent's reply. /// internal const string VerifySql = $$""" WITH rows AS ( @@ -222,6 +230,7 @@ WITH ORDINALITY AS element(part, position) FROM {{Schema}}.conversation_message WHERE conversation_id = $1 AND role = 'assistant' + AND content ->> 'authorName' IS DISTINCT FROM 'front_voice' AND covers_up_to IS NULL AND content -> 'contents' @> '[{"$type": "text"}]' ), diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs index d5043b5f..8ec17a30 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs @@ -3,7 +3,7 @@ namespace AgentCore.Infrastructure.Conversation.Postgres { - /// Reads store 1's words beside the hashes store 3 holds for them, for a retroactive check. + /// Reads the message store's words beside the hashes the audit store holds for them, for a retroactive check. internal static class PostgresConversationVerification { /// Reads one conversation's spoken turns beside the hashes the audit chain holds for them. @@ -35,10 +35,10 @@ public static async Task> ReadSpokenTurnsAsy } } - /// One spoken turn, as store 1 holds it and as store 3 proves it. + /// One spoken turn, as the message store holds it and as the audit store proves it. /// The turn, which is the join between the two stores. /// - /// Every step's text store 1 holds for the turn, concatenated in row order. A barge-in cuts these rows + /// Every step's text the message store holds for the turn, concatenated in row order. A barge-in cuts these rows /// down to what the caller heard, so for a cut turn this already holds the shown words, not the words /// the model produced. /// diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs index e26ef3e1..1782e6e7 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs @@ -11,7 +11,7 @@ namespace AgentCore.Infrastructure.Conversation.Postgres { - /// The words of every conversation, in PostgreSQL: store 1's rows, and what a turn writes beside them. + /// The words of every conversation, in PostgreSQL: the message store's rows, and what a turn writes beside them. /// Binds the words to the pool every statement runs on. The store owns the pool; this does not. /// The pool. internal sealed class PostgresConversationWords(NpgsqlDataSource dataSource) @@ -131,7 +131,7 @@ private static async ValueTask RefusalAsync( } } - return new InvalidOperationException($"Store 0 holds no conversation '{conversationId}' to append words to."); + return new InvalidOperationException($"The conversation store holds no conversation '{conversationId}' to append words to."); } /// diff --git a/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql b/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql index c4f41fa8..6c65f191 100644 --- a/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql +++ b/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql @@ -2,7 +2,7 @@ -- with the words in the same commit. response_continuation shrinks to the one fact worth keeping, which -- conversation a response id continues, and when that row was written for the retention sweep. -- --- The rows whose store_id names a conversation were the old same-id pointer at store 0's own state; the +-- The rows whose store_id names a conversation were the old same-id pointer at the conversation store's own state; the -- conversation table already answers for those ids, so the pointer rows go. DELETE FROM agentcore.response_continuation rc WHERE EXISTS ( diff --git a/src/AgentCore.Infrastructure/Telephony/Telnyx/.gitkeep b/src/AgentCore.Infrastructure/Telephony/Telnyx/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs b/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs deleted file mode 100644 index a3d1b699..00000000 --- a/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs +++ /dev/null @@ -1,276 +0,0 @@ -using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Audit; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Xunit; -using Xunit.Sdk; - -namespace AgentCore.Application.Tests.Audit -{ - /// - /// The one place that knows both vocabularies: a neutral fact of a conversation, and a row of the chain of D23. - /// - public sealed class AuditConversationObserverTests - { - private const string ConversationId = "conversation-1"; - - private static readonly DateTimeOffset Moment = new(2026, 8, 15, 9, 30, 0, TimeSpan.Zero); - - /// The kinds that are counted or logged or both, and stored nowhere. - public static TheoryData DiagnosticKinds => - [ - ConversationEventKind.ModerationUnavailable, - ConversationEventKind.ModerationClean, - ConversationEventKind.EmptyReply, - ConversationEventKind.ExtractionFailed, - ConversationEventKind.TranscriptWriteFailed, - ConversationEventKind.StateRestorePartial, - ConversationEventKind.TranscriptResyncFailed, - ConversationEventKind.RunFaulted, - ]; - - /// Every kind, once, across the two tables above. - [Fact] - public void TheTwoTables_BetweenThemNameEveryKind() - { - IEnumerable named = - [ - .. DiagnosticKinds.Select(row => (ConversationEventKind)((ITheoryDataRow)row).GetData()[0]!), - .. StoredKinds.Select(row => (ConversationEventKind)((ITheoryDataRow)row).GetData()[0]!), - ]; - - Assert.Equal([.. Enum.GetValues().Order()], [.. named.Order()]); - } - - /// The eight kinds the store keeps, beside the token each one writes. - public static TheoryData StoredKinds => - new() - { - { ConversationEventKind.ConversationStarted, AuditEventKind.ConversationStarted }, - { ConversationEventKind.PromptFlagged, AuditEventKind.PromptFlagged }, - { ConversationEventKind.ToolFailed, AuditEventKind.ToolFailed }, - { ConversationEventKind.TurnCompleted, AuditEventKind.TurnCompleted }, - { ConversationEventKind.ReplyInterrupted, AuditEventKind.ReplyInterrupted }, - { ConversationEventKind.ConversationEnded, AuditEventKind.ConversationEnded }, - { ConversationEventKind.TurnSuperseded, AuditEventKind.TurnSuperseded }, - { ConversationEventKind.TurnRefused, AuditEventKind.TurnRefused }, - }; - - [Theory] - [MemberData(nameof(DiagnosticKinds))] - public async Task AFactWithNoEventId_WritesNoRow(ConversationEventKind kind) - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - - // A diagnostic-only event leaves EventId null precisely because it takes no row. - await observer.OnConversationEventAsync(Event(kind), TestContext.Current.CancellationToken); - - Assert.Empty(sink.Events); - } - - [Theory] - [MemberData(nameof(StoredKinds))] - public async Task AStoredFact_BecomesARowOfItsOwnKind(ConversationEventKind kind, AuditEventKind expected) - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - - await observer.OnConversationEventAsync( - Event(kind, eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7()), - TestContext.Current.CancellationToken); - - AuditEvent written = Assert.Single(sink.Events); - Assert.Equal(expected, written.Kind); - } - - [Fact] - public async Task ItCopiesTheIdentityStraightThrough() - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - Guid id = Guid.CreateVersion7(); - - await observer.OnConversationEventAsync( - new ConversationEvent - { - ConversationId = ConversationId, - Kind = ConversationEventKind.ConversationStarted, - OccurredAt = DateTimeOffset.UnixEpoch, - EventId = id, - }, - CancellationToken.None); - - Assert.Equal(id, Assert.Single(sink.EventsOf(ConversationId)).EventId); - } - - [Fact] - public async Task ItCopiesTheAmendmentStraightThrough() - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - Guid turn = Guid.CreateVersion7(); - Guid cut = Guid.CreateVersion7(); - - await observer.OnConversationEventAsync( - new ConversationEvent - { - ConversationId = ConversationId, - Kind = ConversationEventKind.ReplyInterrupted, - OccurredAt = DateTimeOffset.UnixEpoch, - EventId = cut, - AmendsEventId = turn, - TurnIndex = 0, - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText("heard").Value, - [AuditPayloadKeys.DurationUntilInterruptMs] = "120", - }, - }, - CancellationToken.None); - - AuditEvent written = Assert.Single(sink.EventsOf(ConversationId)); - Assert.Equal(cut, written.EventId); - Assert.Equal(turn, written.AmendsEventId); - } - - [Fact] - public async Task AFactThatCorrectsNothing_LeavesTheAmendmentUnset() - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - - await observer.OnConversationEventAsync( - Event(ConversationEventKind.TurnCompleted, eventId: Guid.CreateVersion7()), - TestContext.Current.CancellationToken); - - Assert.Null(Assert.Single(sink.Events).AmendsEventId); - } - - [Fact] - public async Task TheRow_CarriesTheConversationTheTurnAndTheMomentUnchanged() - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - Dictionary payload = new(StringComparer.Ordinal) - { - [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText("hello there.").Value, - [AuditPayloadKeys.StageBefore] = "greeting", - [AuditPayloadKeys.StageAfter] = "triage", - }; - - await observer.OnConversationEventAsync( - Event(ConversationEventKind.TurnCompleted, eventId: Guid.CreateVersion7(), turnIndex: 5, payload: payload), - TestContext.Current.CancellationToken); - - AuditEvent written = Assert.Single(sink.Events); - Assert.Equal(ConversationId, written.ConversationId); - Assert.Equal(5, written.TurnIndex); - Assert.Equal(Moment, written.OccurredAt); - Assert.Equal(payload, written.Payload); - } - - [Fact] - public async Task AFactAboutTheConversationItself_CarriesNoTurn() - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - - await observer.OnConversationEventAsync( - Event(ConversationEventKind.ConversationStarted, eventId: Guid.CreateVersion7()), - TestContext.Current.CancellationToken); - - Assert.Null(Assert.Single(sink.Events).TurnIndex); - } - - [Fact] - public async Task ManyFacts_ReachTheSinkInTheOrderTheConversationRaisedThem() - { - InMemoryAuditSink sink = new(); - AuditConversationObserver observer = new(sink); - CancellationToken token = TestContext.Current.CancellationToken; - - await observer.OnConversationEventAsync(Event(ConversationEventKind.ConversationStarted, eventId: Guid.CreateVersion7()), token); - - // The two diagnostic facts between them take no identity, so they write no row. - await observer.OnConversationEventAsync(Event(ConversationEventKind.ModerationClean), token); - await observer.OnConversationEventAsync( - Event(ConversationEventKind.TurnCompleted, eventId: Guid.CreateVersion7(), turnIndex: 0), token); - await observer.OnConversationEventAsync(Event(ConversationEventKind.EmptyReply, turnIndex: 0), token); - await observer.OnConversationEventAsync(Event(ConversationEventKind.ConversationEnded, eventId: Guid.CreateVersion7()), token); - - Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted, AuditEventKind.ConversationEnded], - sink.EventsOf(ConversationId).Select(item => item.Kind).ToArray()); - } - - [Fact] - public void NoSink_IsRefused() - { - _ = Assert.Throws(() => new AuditConversationObserver(null!)); - } - - [Fact] - public async Task NoEvent_IsRefused() - { - _ = await Assert.ThrowsAsync(async () => - await new AuditConversationObserver(new InMemoryAuditSink()) - .OnConversationEventAsync(null!, TestContext.Current.CancellationToken)); - } - - private static ConversationEvent Event( - ConversationEventKind kind, - Guid? eventId = null, - int? turnIndex = null, - Guid? amends = null, - IReadOnlyDictionary? payload = null) - { - return new() - { - ConversationId = ConversationId, - Kind = kind, - OccurredAt = Moment, - EventId = eventId, - TurnIndex = turnIndex, - AmendsEventId = amends, - Payload = payload ?? RequiredPayload(kind), - }; - } - - /// - /// The payload each kind must carry to be a legal event, per . - /// - private static Dictionary RequiredPayload(ConversationEventKind kind) - { - return kind switch - { - ConversationEventKind.ReplyInterrupted => new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText("the belt ships").Value, - }, - ConversationEventKind.PromptFlagged => new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ModerationCategories] = "harassment", - }, - ConversationEventKind.ConversationEnded => new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.EndReason] = ConversationEndReasons.ToToken(ConversationEndReason.CallerHungUp), - }, - ConversationEventKind.ConversationStarted - or ConversationEventKind.ModerationUnavailable - or ConversationEventKind.ModerationClean - or ConversationEventKind.ToolFailed - or ConversationEventKind.EmptyReply - or ConversationEventKind.ExtractionFailed - or ConversationEventKind.TurnCompleted - or ConversationEventKind.TurnSuperseded - or ConversationEventKind.TurnRefused - or ConversationEventKind.TranscriptWriteFailed - or ConversationEventKind.StateRestorePartial - or ConversationEventKind.TranscriptResyncFailed - or ConversationEventKind.RunFaulted => new Dictionary(StringComparer.Ordinal), - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "The conversation event vocabulary is closed, and this value is not in it."), - }; - } - } -} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs index 22f9f72d..f3efd821 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs @@ -3,17 +3,17 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.Domain; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain.Audit; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; namespace AgentCore.Application.Tests.Audit { - /// The four ways section 4 lets a conversation end, and what each one writes to the chain. + /// The four ways a conversation can end, and what each one writes to the chain. public sealed class ConversationSessionAuditConversationEndedTests { [Fact] @@ -27,7 +27,7 @@ public async Task ATerminalStage_ClosesTheChainWithOneConversationEndedEvent() _ = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); Assert.True(session.IsComplete); - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + AuditEvent ended = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ConversationEnded); Assert.Null(ended.TurnIndex); // The reason is one token of the closed set, so a report can count it. The terminal stage is @@ -55,15 +55,59 @@ public async Task ATerminalStage_DatesTheEndAfterTheTurnsRowsLanded() TurnResult turn = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); Assert.True(turn.IsTerminal); - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + AuditEvent ended = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ConversationEnded); Assert.NotNull(store.LandedAt); Assert.True( ended.OccurredAt >= store.LandedAt, $"conversation.ended is dated {ended.OccurredAt:O}, before the turn's rows landed at {store.LandedAt:O}."); } + // A conversation that a terminal stage ended stays ended when it is loaded again. The turn it + // refuses, and a host end that is then not a first end, write no row after conversation.ended. [Fact] - public void AHostThatEndsTheConversation_ClosesTheChainOnce() + public async Task AReloadedEndedConversation_WritesNoRowAfterItsEnd() + { + using SequencedChatClient reply = new("goodbye."); + using SequencedChatClient fill = new(SaidGoodbye); + InMemoryAuditSink sink = new(); + ConversationSessionFactory factory = Build(PolicyYaml, reply, fill, auditSink: sink, store: new InMemoryConversationStore()); + ConversationSession first = factory.Create("conversation-1"); + _ = await first.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + ConversationSession second = factory.Create("conversation-1"); + _ = await Assert.ThrowsAsync( + () => second.RunTurnAsync("are you there?", TestContext.Current.CancellationToken)); + Assert.False(second.EndConversation(ConversationEndReason.CallerHungUp)); + + IReadOnlyList rows = await second.RowsAsync(sink); + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted, AuditEventKind.ConversationEnded], + rows.Select(row => row.Kind)); + } + + // TurnResult.EndedAt: every row of a turn carries the moment it ended, not the moment its words landed. + [Fact] + public async Task ATurnRow_IsDatedWhenTheTurnEnded_NotWhenItsWordsLanded() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + TestTimeProvider clock = new(); + SlowAppends store = new(new InMemoryConversationStore(), clock, TimeSpan.FromSeconds(3)); + ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock, auditSink: sink, store: store) + .Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + AuditEvent completed = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnCompleted); + Assert.True(store.LandedAt > turn.EndedAt); + Assert.Equal(turn.EndedAt, completed.OccurredAt); + } + + [Fact] + public async Task AHostThatEndsTheConversation_ClosesTheChainOnce() { using SequencedChatClient reply = new("hello there."); using SequencedChatClient fill = new(StayingNull); @@ -73,7 +117,9 @@ public void AHostThatEndsTheConversation_ClosesTheChainOnce() Assert.True(session.EndConversation(ConversationEndReason.CallerHungUp)); Assert.False(session.EndConversation(ConversationEndReason.CallerHungUp)); - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + IReadOnlyList rows = await session.RowsAsync(sink); + Assert.Equal([AuditEventKind.ConversationStarted, AuditEventKind.ConversationEnded], rows.Select(item => item.Kind)); + AuditEvent ended = rows[1]; Assert.Equal("caller.hangup", ended.Payload[AuditPayloadKeys.EndReason]); // The machine never ran, so no stage closed the conversation and no stage rides on the event. @@ -81,24 +127,26 @@ public void AHostThatEndsTheConversation_ClosesTheChainOnce() Assert.True(session.IsComplete); } - /// Section 11, item 5: the conversation goes to a human through the conference pattern. + /// The conversation goes to a human through the conference pattern. [Fact] - public void ATransferToAHuman_ClosesTheChainWithItsOwnReason() + public async Task ATransferToAHuman_ClosesTheChainWithItsOwnReason() { using SequencedChatClient reply = new("one moment please."); using SequencedChatClient fill = new(StayingNull); InMemoryAuditSink sink = new(); ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - // The adapter joins the conversation to a conference and never sends the transfer command (T27). + // The adapter joins the conversation to a conference and never sends the transfer command. Assert.True(session.EndConversation(ConversationEndReason.TransferredToHuman)); - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + IReadOnlyList rows = await session.RowsAsync(sink); + Assert.Equal([AuditEventKind.ConversationStarted, AuditEventKind.ConversationEnded], rows.Select(item => item.Kind)); + AuditEvent ended = rows[1]; Assert.Equal("agent.transferred", ended.Payload[AuditPayloadKeys.EndReason]); } [Fact] - public void AReasonOutsideTheClosedSet_EndsNoConversation() + public async Task AReasonOutsideTheClosedSet_EndsNoConversation() { using SequencedChatClient reply = new("hello there."); using SequencedChatClient fill = new(StayingNull); @@ -109,7 +157,7 @@ public void AReasonOutsideTheClosedSet_EndsNoConversation() // Nothing moved. The conversation still runs, and the chain still holds only its first event. Assert.False(session.IsComplete); - Assert.DoesNotContain(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + Assert.DoesNotContain(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ConversationEnded); } /// A store whose appends take time on the test's clock, and that notes when the last one landed. diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs index bde84f6c..229122f4 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs @@ -1,32 +1,35 @@ using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain; using AgentCore.Domain.Audit; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; namespace AgentCore.Application.Tests.Audit { /// - /// The turn loop writes the audit chain of D23, and the sink never sits on the turn. + /// The turn loop writes the audit chain, and the sink never sits on the turn. /// public sealed class ConversationSessionAuditEventsTests { // The events, and the identity a later amendment names. + // The start is raised when the session opens the store, so a turn runs first. [Fact] - public void ANewSession_WritesTheConversationStartedEvent() + public async Task AFirstTurn_WritesTheConversationStartedEventFirst() { using SequencedChatClient reply = new("hello there."); using SequencedChatClient fill = new(StayingNull); InMemoryAuditSink sink = new(); ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - AuditEvent started = Assert.Single(sink.EventsOf("conversation-1")); + AuditEvent started = (await session.RowsAsync(sink))[0]; Assert.Equal(AuditEventKind.ConversationStarted, started.Kind); Assert.NotEqual(Guid.Empty, started.EventId); Assert.Null(started.TurnIndex); @@ -34,6 +37,20 @@ public void ANewSession_WritesTheConversationStartedEvent() Assert.Equal(session.ConversationId, started.ConversationId); } + // The audit hook alone writes the rows, so one turn is exactly two of them. + [Fact] + public async Task OneTurnWritesEachOfItsRowsOnce() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal([AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], (await session.RowsAsync(sink)).Select(row => row.Kind)); + } + [Fact] public async Task ASecondSessionOfOneConversation_CollidesWithNothing() { @@ -53,12 +70,12 @@ public async Task ASecondSessionOfOneConversation_CollidesWithNothing() _ = await second.RunTurnAsync("still there?", TestContext.Current.CancellationToken); await second.FlushTranscriptAsync(); - IReadOnlyList written = sink.EventsOf("conversation-1"); + IReadOnlyList written = await second.RowsAsync(sink); // The shape is the assertion, and both halves of it are the point. // // ARRIVAL: a second session that restarted its counter at zero would have every event it raised - // refused by store 3, and a resumed conversation would lose its whole audit trail. The proof is + // refused by the store, and a resumed conversation would lose its whole audit trail. The proof is // that the second session's two events are HERE — distinctness proves nothing, // because InMemoryAuditSink enforces no key and Guid.CreateVersion7 is unique by // construction, so that assertion passed just as well over the two events that never arrived. @@ -92,8 +109,9 @@ public async Task AFinishedTurn_WritesOneTurnCompletedEventWithBothStages() clock.Advance(TimeSpan.FromSeconds(12)); TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - AuditEvent completed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.TurnCompleted); - Assert.Same(completed, sink.EventsOf("conversation-1")[1]); + IReadOnlyList rows = await session.RowsAsync(sink); + AuditEvent completed = Assert.Single(rows, item => item.Kind == AuditEventKind.TurnCompleted); + Assert.Same(completed, rows[1]); Assert.Equal(0, completed.TurnIndex); Assert.Equal(AuditHash.OfText("hello there.").Value, completed.Payload[AuditPayloadKeys.ReplyTextSha256]); Assert.Equal("greeting", completed.Payload[AuditPayloadKeys.StageBefore]); @@ -123,17 +141,18 @@ public async Task ABargeIn_WritesASecondEventThatAmendsTheTurnEvent() Assert.False(await updates.MoveNextAsync()); } - IReadOnlyList events = sink.EventsOf("conversation-1"); + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); AuditEvent cut = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); - // T23: the chain is append-only, so an amendment is a second event that references the first. - IReadOnlyList chain = sink.EventsOf("conversation-1"); + // The chain is append-only, so an amendment is a second event that references the first. + IReadOnlyList chain = events; Assert.Equal(completed.EventId, cut.AmendsEventId); + Assert.Equal(completed.OccurredAt, cut.OccurredAt); Assert.True(chain.ToList().IndexOf(cut) > chain.ToList().IndexOf(completed)); Assert.Equal(completed.TurnIndex, cut.TurnIndex); - // Item 6a: the event records the text the caller ACTUALLY HEARD. Nothing here is estimated, + // The event records the text the caller ACTUALLY HEARD. Nothing here is estimated, // because the relay reported both values on its interrupt frame. Assert.Equal(AuditHash.OfText("hel").Value, cut.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); Assert.Equal("120", cut.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs index bf3667d9..bd9e2b7a 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs @@ -1,23 +1,23 @@ using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Diagnostics; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain; using AgentCore.Domain.Audit; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; namespace AgentCore.Application.Tests.Audit { /// - /// Item 6 and D23, from the sink's side: the chain the sink holds verifies, the write never sits + /// From the sink's side: the chain the sink holds verifies, the write never sits /// on the turn, and a sink that misbehaves — or is absent — never harms the caller. /// public sealed class ConversationSessionAuditSinkTests { - // Item 6: the chain the events form verifies. + // The chain the events form verifies. [Fact] public async Task TheEventsOfOneConversation_FormAChainThatVerifies() { @@ -30,18 +30,18 @@ public async Task TheEventsOfOneConversation_FormAChainThatVerifies() _ = await session.RunTurnAsync("hi", token); _ = await session.RunTurnAsync("goodbye", token); - IReadOnlyList events = sink.EventsOf("conversation-1"); + IReadOnlyList events = await session.RowsAsync(sink); // Four facts, four identities, none of them repeated. What orders them is // audit_event.sequence, which the store assigns and which this test never sees. Assert.Equal(4, events.Count); Assert.Equal(events.Count, events.Select(item => item.EventId).Distinct().Count()); - // This is chain_check of section 11, item 6, run over what the turn loop produced. + // This is the chain check, run over what the turn loop produced. Assert.All(events, AuditEventVocabulary.Validate); } - // D23: the write never sits on the turn. + // The write never sits on the turn. [Fact] public async Task TheTurn_FinishesWhileTheAppendIsStillOpen() { @@ -50,26 +50,23 @@ public async Task TheTurn_FinishesWhileTheAppendIsStillOpen() BlockingAuditSink sink = new(); ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - // Section 7: a durable insert costs 13 ms at p50 and 32 ms at p99, against 91 nanoseconds to + // A durable insert costs 13 ms at p50 and 32 ms at p99, against 91 nanoseconds to // enqueue. The turn therefore finishes with no append complete at all. TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); Assert.Equal("hello there.", turn.ReplyText); // The whole turn ran and returned while conversation.started is still inside the sink. The - // turn.completed event is queued behind it rather than racing past it, because the events of - // one conversation reach an observer in the order the conversation raised them, so exactly one has arrived. + // turn.completed event is queued behind it rather than racing past it, because the audit hook takes + // the notices of one conversation in the order the conversation raised them, so exactly one has arrived. + await sink.Entered; _ = Assert.Single(sink.Events); sink.Release(); - // Off the turn, and only now. The queued event lands on the dispatcher's own task, which is + // Off the turn, and only now. The queued event lands on the audit hook's own reader, which is // where a slow sink is paid for. - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); - while (sink.Events.Count < 2 && DateTimeOffset.UtcNow < deadline) - { - await Task.Delay(10, TestContext.Current.CancellationToken); - } + await session.FlushNoticesAsync(); Assert.Equal( [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], @@ -86,10 +83,11 @@ public async Task ASinkThatRefusesAnEvent_IsLoggedAndTheTurnGoesOn() .Create("conversation-1"); TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); - // Audit is a record of the conversation and never a part of it. + // Audit is a record of the conversation and never a part of it. Each refused row is a gap, logged once. Assert.Equal("hello there.", turn.ReplyText); - Assert.NotEmpty(logger.Of(5)); + Assert.Equal(2, logger.Of(5).Count); Assert.All(logger.Of(5), line => Assert.Equal(LogLevel.Error, line.Level)); } @@ -109,20 +107,22 @@ public async Task ASessionWithNoSink_RunsATurnAndThrowsNothing() [Fact] public async Task TheInMemorySink_KeepsTheConversationsApart() { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); + using SequencedChatClient reply = new("hello there.", "hello again."); + using SequencedChatClient fill = new(StayingNull, StayingNull); InMemoryAuditSink sink = new(); ConversationSessionFactory factory = Build(PolicyYaml, reply, fill, auditSink: sink); ConversationSession first = factory.Create("conversation-1"); ConversationSession second = factory.Create("conversation-2"); _ = await first.RunTurnAsync("hi", TestContext.Current.CancellationToken); + _ = await second.RunTurnAsync("hi", TestContext.Current.CancellationToken); // One sink serves every conversation, because a session names itself on every event. + Assert.All(await first.RowsAsync(sink), row => Assert.Equal("conversation-1", row.ConversationId)); + Assert.All(await second.RowsAsync(sink), row => Assert.Equal("conversation-2", row.ConversationId)); Assert.Equal(2, sink.EventsOf("conversation-1").Count); - _ = Assert.Single(sink.EventsOf("conversation-2")); - Assert.Equal(3, sink.Events.Count); - Assert.Equal("conversation-2", second.ConversationId); + Assert.Equal(2, sink.EventsOf("conversation-2").Count); + Assert.Equal(4, sink.Events.Count); } } } diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs index fe382594..3929c283 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs @@ -4,11 +4,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; +using AgentCore.Domain.Audit; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Audit { @@ -69,10 +71,10 @@ internal static ConversationSessionFactory Build( ILogger? logger = null, IConversationStore? store = null) { - // ConversationObservers.Standard takes a required sink, because the composition root resolves - // providers.audit for every host and falls back to the in-process memory kind. An optional - // parameter has to be a compile-time constant, so the default is spelled here instead — a fact - // that does not care where its events land gets a fresh in-memory sink. + // The audit hook takes a required sink, because the composition root resolves providers.audit for + // every host and falls back to the in-process memory kind. An optional parameter has to be a + // compile-time constant, so the default is spelled here instead — a fact that does not care where + // its events land gets a fresh in-memory sink. IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); @@ -96,7 +98,14 @@ internal static ConversationSessionFactory Build( ConversationSessionFactory.CreateExtractor(compiled, chatClients), timeProvider, logger, - ConversationObservers.Standard(sink, logger)); + hooks: BuiltInHooks.Create(sink, logger)); + } + + /// Waits for the audit hook, then reads the conversation's rows. + internal static async Task> RowsAsync(this ConversationSession session, InMemoryAuditSink sink) + { + await session.FlushNoticesAsync(); + return sink.EventsOf(session.ConversationId); } } } diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs index 98961006..564643e1 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs @@ -1,19 +1,19 @@ using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain; using AgentCore.Domain.Audit; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; namespace AgentCore.Application.Tests.Audit { - /// §8.7 row six is the turn-level fact of a tool failure; these are the conversation-level ones. + /// The tool.failed rows: one per call whose fault the model cannot answer, and one per tool it named that no one declared. public sealed class ConversationSessionAuditToolFailureTests { [Fact] - public async Task TheFourthConsecutiveToolFailure_WritesAToolFailedEventBeforeTheTurnEvent() + public async Task TheFourFailedCallsAreFourToolFailedRowsBeforeTheTurnEvent() { using LoopingToolCallingChatClient reply = new(); InMemoryAuditSink sink = new(); @@ -21,57 +21,74 @@ public async Task TheFourthConsecutiveToolFailure_WritesAToolFailedEventBeforeTh _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - IReadOnlyList events = sink.EventsOf("conversation-1"); + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent[] failures = [.. events.Where(item => item.Kind == AuditEventKind.ToolFailed)]; AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); - // The kind is raised at two altitudes and both are here: one row for each conversation that failed, - // and one for the turn that ended because of them. Five in all, and every one of them before - // the turn event. - Assert.Equal(5, failures.Length); - List order = [.. sink.EventsOf("conversation-1")]; + // One row per failed call, and every one of them before the turn event. + Assert.Equal(4, failures.Length); + List order = [.. events]; Assert.All(failures, failure => Assert.True(order.IndexOf(failure) < order.IndexOf(completed))); Assert.All(failures, failure => Assert.Equal(0, failure.TurnIndex)); - // The turn-level row is the one section 8.7 row six has always written, and it is unchanged: - // no tool is named, because the fault reaches the session with no function name on it, and a - // missing fact is an absent key rather than the word "unknown". - AuditEvent turnLevel = Assert.Single( - failures, - failure => !failure.Payload.ContainsKey(AuditPayloadKeys.ToolCallId)); - Assert.False(turnLevel.Payload.ContainsKey(AuditPayloadKeys.ToolName)); - Assert.Contains( - ThrowingToolBuilder.Message, - turnLevel.Payload[AuditPayloadKeys.ToolError], - StringComparison.Ordinal); - - // The four call-level rows are the new fact, and each one names its tool. The fourth is the - // one that spent the budget: the framework does not capture that exception at all, so it never - // reaches CreateResponseMessages, and only the invocation hook sees it. - AuditEvent[] conversations = [.. failures.Where(failure => failure.Payload.ContainsKey(AuditPayloadKeys.ToolCallId))]; - Assert.Equal(4, conversations.Length); - Assert.All(conversations, conversation => Assert.Equal("lookup_order", conversation.Payload[AuditPayloadKeys.ToolName])); + // Each row names its tool. The fourth is the one that spent the budget: the framework does not + // capture that exception at all, so it never reaches CreateResponseMessages, and only the tool + // middleware sees it. + Assert.All(failures, failure => Assert.Equal("lookup_order", failure.Payload[AuditPayloadKeys.ToolName])); Assert.All( - conversations, - conversation => Assert.Equal( + failures, + failure => Assert.Equal( ToolFailureKinds.ToToken(ToolFailureKind.Faulted), - conversation.Payload[AuditPayloadKeys.ToolFailureKind])); + failure.Payload[AuditPayloadKeys.ToolFailureKind])); Assert.All( - conversations, - conversation => Assert.Contains( + failures, + failure => Assert.Contains( ThrowingToolBuilder.Message, - conversation.Payload[AuditPayloadKeys.ToolError], + failure.Payload[AuditPayloadKeys.ToolError], StringComparison.Ordinal)); - // Four conversations, four ids, and none of them repeated. The name alone could never have told them + // Four calls, four ids, and none of them repeated. The name alone could never have told them // apart. - string[] ids = [.. conversations.Select(conversation => conversation.Payload[AuditPayloadKeys.ToolCallId])]; + string[] ids = [.. failures.Select(failure => failure.Payload[AuditPayloadKeys.ToolCallId])]; Assert.Equal(4, ids.Distinct(StringComparer.Ordinal).Count()); - // The chain still verifies over every one of the new keys. + // The chain still verifies over every one of these keys. Assert.All(events, AuditEventVocabulary.Validate); } + // An agent-as-tool whose child spends its budget faults too, so the child's calls and the agent tool's own + // call are each a row: 4 delegations of 4 child calls. + [Fact] + public async Task ANestedFaultIsARowForEveryChildCallAndForEveryAgentToolCall() + { + const string NestedYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: ask_specialist, kind: agent, agent: specialist, description: Ask the specialist. } + - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet", tools: [ ask_specialist ] } + - { id: specialist, instructions: "answer", tools: [ lookup_order ] } + entries: + main: + agent: greeter + """; + using LoopingToolCallingChatClient reply = new(); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(NestedYaml, reply, null, new ThrowingToolBuilder().Create, auditSink: sink).Create("conversation-1"); + + _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + AuditEvent[] failures = [.. (await session.RowsAsync(sink)).Where(item => item.Kind == AuditEventKind.ToolFailed)]; + Assert.Equal( + [("ask_specialist", 4), ("lookup_order", 16)], + failures.GroupBy(failure => failure.Payload[AuditPayloadKeys.ToolName]).Select(group => (group.Key, group.Count())).Order()); + Assert.Equal(20, failures.Select(failure => failure.Payload[AuditPayloadKeys.ToolCallId]).Distinct(StringComparer.Ordinal).Count()); + } + [Fact] public async Task AToolThatFaults_NamesTheToolAndTheConversationIdInTheChain() { @@ -84,7 +101,7 @@ public async Task AToolThatFaults_NamesTheToolAndTheConversationIdInTheChain() TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - AuditEvent failed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); + AuditEvent failed = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ToolFailed); Assert.Equal("lookup_order", failed.Payload[AuditPayloadKeys.ToolName]); Assert.Equal(reply.CallIds[0], failed.Payload[AuditPayloadKeys.ToolCallId]); Assert.Equal( @@ -113,7 +130,7 @@ public async Task AHallucinatedToolName_ReachesTheChain() TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - AuditEvent failed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); + AuditEvent failed = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ToolFailed); // The name recorded is the name the MODEL called, so a reader that joins it to tools[].id // finds nothing — which is the finding. @@ -140,7 +157,7 @@ public async Task TwoParallelCallsToTheSameTool_AreTwoDistinguishableRecords() _ = await session.RunTurnAsync("where are my two orders", TestContext.Current.CancellationToken); - AuditEvent[] failures = [.. sink.EventsOf("conversation-1").Where(item => item.Kind == AuditEventKind.ToolFailed)]; + AuditEvent[] failures = [.. (await session.RowsAsync(sink)).Where(item => item.Kind == AuditEventKind.ToolFailed)]; Assert.Equal(2, failures.Length); Assert.All(failures, failure => Assert.Equal("lookup_order", failure.Payload[AuditPayloadKeys.ToolName])); @@ -149,13 +166,13 @@ public async Task TwoParallelCallsToTheSameTool_AreTwoDistinguishableRecords() failures.Select(failure => failure.Payload[AuditPayloadKeys.ToolCallId]).Order(StringComparer.Ordinal).ToArray()); // Two records, and the chain still verifies over them. - Assert.All(sink.EventsOf("conversation-1"), AuditEventVocabulary.Validate); + Assert.All(await session.RowsAsync(sink), AuditEventVocabulary.Validate); } [Fact] public async Task AToolWhoseFaultTheModelCanAnswer_WritesNoToolFailedEventAtAll() { - // The converged design, from the chain's side: the tool ANSWERED. The framework sees a result + // From the chain's side: the tool ANSWERED. The framework sees a result // and no exception, so nothing failed as far as it is concerned and no row is written. using NamedToolCallingChatClient reply = new("lookup_order", "That order is already closed."); InMemoryAuditSink sink = new(); @@ -164,29 +181,31 @@ public async Task AToolWhoseFaultTheModelCanAnswer_WritesNoToolFailedEventAtAll( TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - Assert.DoesNotContain(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); + Assert.DoesNotContain(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ToolFailed); Assert.Null(turn.Failure); Assert.Equal("That order is already closed.", turn.ReplyText); } - /// T44: one compiled agent serves every conversation, so nothing per conversation may live on it. + /// One compiled agent serves every conversation, so nothing per conversation may live on it. [Fact] public async Task TwoConversationsAtOnce_DoNotPolluteEachOthersRecords() { const int FanOut = 8; InMemoryAuditSink sink = new(); - // One factory, one document, one compiled agent behind the sessions — the shape T44 pins. + // One factory, one document, one compiled agent behind the sessions. using NamedToolCallingChatClient reply = new("lookup_order", "I could not reach the order system."); ConversationSessionFactory factory = Build(ToolYaml, reply, null, new UnreachableEndpointToolBuilder().Create, auditSink: sink); CancellationToken token = TestContext.Current.CancellationToken; using Barrier gate = new(FanOut); + ConversationSession[] sessions = [.. Enumerable.Range(0, FanOut).Select(index => factory.Create($"conversation-{index}"))]; + await Task.WhenAll(Enumerable.Range(0, FanOut).Select(index => Task.Run( async () => { - ConversationSession session = factory.Create($"conversation-{index}"); + ConversationSession session = sessions[index]; gate.SignalAndWait(token); _ = await session.RunTurnAsync("where is my order", token).ConfigureAwait(false); }, @@ -194,7 +213,7 @@ await Task.WhenAll(Enumerable.Range(0, FanOut).Select(index => Task.Run( for (int index = 0; index < FanOut; index++) { - IReadOnlyList events = sink.EventsOf($"conversation-{index}"); + IReadOnlyList events = await sessions[index].RowsAsync(sink); // Each conversation recorded exactly its own failure, under its own id, and its three events kept // their own order. A record that had leaked between two flows would show up as a second diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs index cf3bc787..358484fa 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs @@ -5,8 +5,8 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Evaluation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Diagnostics; using AgentCore.Application.Tests.Evaluation.Fakes; @@ -16,25 +16,13 @@ using Microsoft.Extensions.Logging; using Xunit; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Audit { /// /// Moderation reads what the caller said before the model runs, and refuses a flagged turn. /// - /// - /// - /// The owner decided this on 2026-08-13. It departs from section 11 item 11, which asked for the - /// agent's REPLY to be moderated and recorded. Recording a harmful reply protects nobody, because - /// the caller already heard it. Reply moderation is withdrawn, because the model carries its own - /// safety training, so prompt.flagged is the only moderation kind the chain has. - /// - /// - /// The one rule that separates prompt.flagged from reply.interrupted is here: the - /// verdict is known BEFORE the model runs, so the event is written before turn.completed and - /// amends nothing. - /// - /// public sealed class ConversationSessionModerationTests { private const string PlainYaml = @@ -79,7 +67,7 @@ public async Task AFlaggedPrompt_WritesTheFlagBeforeTheTurnEvent() _ = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - IReadOnlyList events = sink.EventsOf("conversation-1"); + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent flagged = Assert.Single(events, e => e.Kind == AuditEventKind.PromptFlagged); AuditEvent completed = Assert.Single(events, e => e.Kind == AuditEventKind.TurnCompleted); @@ -99,7 +87,7 @@ public async Task AFlaggedPrompt_CarriesTheCategoriesInTheOrderTheEndpointReturn _ = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - AuditEvent flagged = Assert.Single(sink.EventsOf("conversation-1"), e => e.Kind == AuditEventKind.PromptFlagged); + AuditEvent flagged = Assert.Single(await session.RowsAsync(sink), e => e.Kind == AuditEventKind.PromptFlagged); Assert.Equal("violence,harassment", flagged.Payload[AuditPayloadKeys.ModerationCategories]); } @@ -136,7 +124,7 @@ public async Task AFlaggedPrompt_IsNotAFailure() TurnResult result = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - // A refusal is not a section 8.7 failure. Nothing broke, and the model was never asked. + // A refusal is not a failure. Nothing broke, and the model was never asked. Assert.Null(result.Failure); } @@ -149,7 +137,7 @@ public async Task AFlaggedPrompt_RecordsTheRefusalAsTheTextTheCallerHeard() _ = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - AuditEvent completed = Assert.Single(sink.EventsOf("conversation-1"), e => e.Kind == AuditEventKind.TurnCompleted); + AuditEvent completed = Assert.Single(await session.RowsAsync(sink), e => e.Kind == AuditEventKind.TurnCompleted); Assert.Equal( AuditHash.OfText("I am sorry. I cannot help with that request.").Value, completed.Payload[AuditPayloadKeys.ReplyTextSha256]); @@ -177,12 +165,13 @@ public async Task AFlaggedPrompt_IsLoggedOnceAndNeverCarriesTheWordsThatWereFlag moderation: ScriptedModerationEvaluator.Flagging("harassment")).Create("conversation-1"); _ = await session.RunTurnAsync("the words that were flagged", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); LogLine entry = Assert.Single(logger.Of(6)); Assert.Equal(LogLevel.Warning, entry.Level); Assert.Contains("harassment", entry.Message, StringComparison.Ordinal); - // The flagged words are the content. A log store holds none of the three defences of D23, + // The flagged words are the content. A log store is no place for the words, // so the categories travel and the words do not. Assert.DoesNotContain("the words that were flagged", entry.Message, StringComparison.Ordinal); } @@ -196,7 +185,7 @@ public async Task TheEventsOfAModeratedConversation_FormAChainThatVerifies() _ = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - IReadOnlyList events = sink.EventsOf("conversation-1"); + IReadOnlyList events = await session.RowsAsync(sink); Assert.Equal( [AuditEventKind.ConversationStarted, AuditEventKind.PromptFlagged, AuditEventKind.TurnCompleted], events.Select(e => e.Kind).ToArray()); @@ -218,7 +207,7 @@ public async Task ACleanPrompt_ReachesTheModelAndWritesNoFlag() Assert.Equal("the ordinary reply", result.ReplyText); // The vocabulary holds no reply.cleared. A turn.completed that no flag precedes is the record. - Assert.DoesNotContain(sink.EventsOf("conversation-1"), e => e.Kind == AuditEventKind.PromptFlagged); + Assert.DoesNotContain(await session.RowsAsync(sink), e => e.Kind == AuditEventKind.PromptFlagged); } [Fact] @@ -245,7 +234,7 @@ public async Task AnEndpointThatDidNotAnswer_LetsTheTurnThrough() Assert.Equal(1, model.Calls); Assert.Equal("the ordinary reply", result.ReplyText); - Assert.DoesNotContain(sink.EventsOf("conversation-1"), e => e.Kind == AuditEventKind.PromptFlagged); + Assert.DoesNotContain(await session.RowsAsync(sink), e => e.Kind == AuditEventKind.PromptFlagged); } [Fact] @@ -258,6 +247,7 @@ public async Task AnEndpointThatThrows_LetsTheTurnThroughAndLogsOnce() .Create("conversation-1"); TurnResult result = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); Assert.Equal("the ordinary reply", result.ReplyText); Assert.Equal(1, model.Calls); @@ -278,7 +268,7 @@ public async Task AnEndpointThatFails_WritesNoFlagAndNeverTheWordUnknown() // A missing fact is an absent event, never an event carrying "unknown". The chain rule would // refuse a prompt.flagged with no category anyway. - Assert.DoesNotContain(sink.EventsOf("conversation-1"), e => e.Kind == AuditEventKind.PromptFlagged); + Assert.DoesNotContain(await session.RowsAsync(sink), e => e.Kind == AuditEventKind.PromptFlagged); } [Fact] @@ -291,7 +281,7 @@ public async Task ASessionWithNoModerator_RunsATurnAndWritesNoFlag() TurnResult result = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); Assert.Equal("the ordinary reply", result.ReplyText); - Assert.Equal(2, sink.EventsOf("conversation-1").Count); + Assert.Equal(2, (await session.RowsAsync(sink)).Count); } // The streaming path takes the same decision. @@ -306,7 +296,7 @@ public async Task AFlaggedPrompt_StreamsTheRefusalAndOpensNoModelStream() List spoken = []; await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("...", TestContext.Current.CancellationToken)) { - // The trailing update is the turn's committed ids (design section 6, step E5), not text. + // The trailing update is the turn's committed ids, not text. if (update.Contents.OfType().Any()) { continue; @@ -317,7 +307,7 @@ public async Task AFlaggedPrompt_StreamsTheRefusalAndOpensNoModelStream() Assert.Equal(["I am sorry. I cannot help with that request."], spoken); Assert.Equal(0, model.Calls); - Assert.Contains(sink.EventsOf("conversation-1"), e => e.Kind == AuditEventKind.PromptFlagged); + Assert.Contains(await session.RowsAsync(sink), e => e.Kind == AuditEventKind.PromptFlagged); } [Fact] @@ -343,10 +333,10 @@ private static ConversationSessionFactory Build( ILogger? logger = null, ScriptedModerationEvaluator? moderation = null) { - // ConversationObservers.Standard takes a required sink, because the composition root resolves - // providers.audit for every host and falls back to the in-process memory kind. An optional - // parameter has to be a compile-time constant, so the default is spelled here instead — a fact - // that does not care where its events land gets a fresh in-memory sink. + // The audit hook takes a required sink, because the composition root resolves providers.audit for + // every host and falls back to the in-process memory kind. An optional parameter has to be a + // compile-time constant, so the default is spelled here instead — a fact that does not care where + // its events land gets a fresh in-memory sink. IAuditSinkPort auditSink = sink ?? new InMemoryAuditSink(); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); @@ -356,7 +346,7 @@ private static ConversationSessionFactory Build( _ = chatClients.Route("fill", fill); } - // R3 puts moderation in the chat pipeline of every compiled agent, so the moderator is + // Moderation sits in the chat pipeline of every compiled agent, so the moderator is // bound at compile time and not on the session factory. CompiledAgent compiled = ConfigurationCompiler.CompileAll( document, @@ -371,7 +361,7 @@ private static ConversationSessionFactory Build( ConversationSessionFactory.CreateExtractor(compiled, chatClients), timeProvider: null, logger, - ConversationObservers.Standard(auditSink, logger)); + hooks: BuiltInHooks.Create(auditSink, logger)); } } } diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs index 62215567..45087af2 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using AgentCore.Application.Transcript; using AgentCore.Domain; @@ -7,6 +6,8 @@ using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Audit { @@ -26,7 +27,7 @@ public async Task ASingleStepTurn_HashesTheOneStepItSpoke() _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); AuditEvent completed = Assert.Single( - sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnCompleted); + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnCompleted); Assert.Equal( "92ea4f7debacecc2bb655776bd3406477c3c75d2575d40e3d9d67e763b5d5cec", completed.Payload[AuditPayloadKeys.ReplyTextSha256]); @@ -49,7 +50,7 @@ public async Task AMultiStepTurn_HashesEveryStepInOrder() _ = await session.RunTurnAsync("what does it cost", TestContext.Current.CancellationToken); AuditEvent completed = Assert.Single( - sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnCompleted); + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnCompleted); Assert.Equal( "0aeee3157d498f34407043e72714af88cbb07c27dc76050b8a294e7b96a6df9b", completed.Payload[AuditPayloadKeys.ReplyTextSha256]); @@ -58,7 +59,7 @@ public async Task AMultiStepTurn_HashesEveryStepInOrder() [Fact] public async Task ACutMultiStepTurn_HashesTheGeneratedReplyAndTheHeardTextSeparately() { - // The barge-in is reported after the turn already ran to completion (D28, item 6a), so the + // The barge-in is reported after the turn already ran to completion, so the // model's own output is fixed and deterministic: the whole two-step reply. The relay's report // of what the caller heard is a separate, independent value. turn.completed proves the first; // reply.interrupted proves the second, and the two must not be conflated into one hash. @@ -75,7 +76,7 @@ public async Task ACutMultiStepTurn_HashesTheGeneratedReplyAndTheHeardTextSepara Assert.True(session.Cut( 0, new TurnCut(ProseBesideToolChatClient.Prose + "the total", TimeSpan.FromMilliseconds(1820)))); - IReadOnlyList events = sink.EventsOf(session.ConversationId); + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); AuditEvent interrupted = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); @@ -102,7 +103,7 @@ public async Task AToolThatSpendsItsBudgetAfterTheModelSpoke_HashesTheStepsAndTh "Checking 1. Checking 2. Checking 3. Checking 4.I am sorry. I could not finish that. Please say it again.", await SpokenAsync(session, turnIndex: 0)); AuditEvent completed = Assert.Single( - sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnCompleted); + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnCompleted); Assert.Equal( "40e79eac3b0c25fe5d27e0fd6e0ee02aecc48ff93b81af341e0ec3cdb875812d", completed.Payload[AuditPayloadKeys.ReplyTextSha256]); @@ -130,7 +131,7 @@ public async Task AReaderThatLeavesDuringTheSecondStep_HashesEveryStepItWasShown Assert.Equal("Let me check that for youthe", await SpokenAsync(session, turnIndex: 0)); Assert.Equal("Let me check that for youthe", session.LastTurn?.ReplyText); AuditEvent interrupted = Assert.Single( - sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal( "7b1485a90a236eb1d3a77c433b1e9371d93481283ab337b76b59c3c059bc1d51", interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); @@ -153,7 +154,7 @@ public async Task ALateCutThatNamesNoText_KeepsEveryStepAndHashesThem() Assert.Equal("Let me check that for youthe total comes to eighty", await SpokenAsync(session, turnIndex: 0)); AuditEvent interrupted = Assert.Single( - sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal( "2321bd05672f296a576e169190756dce4be21568a9d3d0542d45375ad056a7d8", interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); diff --git a/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkTests.cs b/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkTests.cs index 8c3c6752..c7466e5a 100644 --- a/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkTests.cs @@ -10,13 +10,6 @@ namespace AgentCore.Application.Tests.Audit /// /// The one queue every audit sink sits behind. /// - /// - /// Section 7 measures a durable insert at 13 ms p50 and 32 ms p99, against 91 nanoseconds to enqueue, - /// and turns that into a rule: an append completes when the event is - /// ACCEPTED and never when it is DURABLE. Before this class the rule was a doc comment, and an - /// adapter that awaited its database broke it silently. These tests fix the rule in one place, so a - /// blocking adapter behind this queue is a correct adapter. - /// public sealed class QueuedAuditSinkTests { private const string ConversationId = "conversation-1"; @@ -59,8 +52,8 @@ public async Task EveryEvent_ReachesTheInnerSink_InTheOrderItWasAppended() await sink.FlushAsync(TestContext.Current.CancellationToken); - // One reader drains the channel, so the chain of D23 reaches the store in the order the conversation - // produced it. That ordering is now a property of this class and not of each adapter. + // One reader drains the channel, so the chain reaches the store in the order the conversation + // produced it. That ordering belongs to this class, not to each adapter. Assert.Equal(ids, inner.Events.Select(item => item.EventId).ToArray()); } @@ -89,31 +82,46 @@ public async Task EventsThatPileUpWhileTheSinkIsBusy_ArriveAsOneBatch() } [Fact] - public async Task AFullQueue_DropsAndReports_AndTheCallerNeverWaits() + public async Task AFullQueueMakesTheCallerWaitAndDropsNothing() { RecordingLogger logger = new(); GatedBatchAuditSink inner = new(); await using QueuedAuditSink sink = new(inner, logger, capacity: 4); await sink.AppendAsync(Event(1), TestContext.Current.CancellationToken); - - // The writer holds the first batch open, so nothing is read while the next seven arrive: four - // fit the queue and three have nowhere to go. await inner.Entered; + List appends = []; for (long sequence = 2; sequence <= 8; sequence++) { - ValueTask append = sink.AppendAsync(Event(sequence), TestContext.Current.CancellationToken); - - // A sink that cannot keep up drops and reports rather than slowing the caller down, so - // even the appends that are refused cost the turn nothing and throw nothing. - Assert.True(append.IsCompleted); - await append; + appends.Add(sink.AppendAsync(Event(sequence), TestContext.Current.CancellationToken).AsTask()); } - Assert.Equal(3, logger.Of(AuditQueueFullEventId).Count); - + Assert.False(appends[^1].IsCompleted); inner.Release(); + await Task.WhenAll(appends); + + await sink.FlushAsync(TestContext.Current.CancellationToken); + Assert.Empty(logger.Of(AuditQueueFullEventId)); + Assert.Equal(8, inner.Written); + } + + // The flush first waits for what is upstream of the queue (the audit hook's delivery). + [Fact] + public async Task AFlushWaitsForTheUpstreamBeforeTheQueue() + { + InMemoryAuditSink inner = new(); + TaskCompletionSource upstream = new(TaskCreationOptions.RunContinuationsAsynchronously); + await using QueuedAuditSink sink = new(inner) { Upstream = () => upstream.Task }; + + Task flushing = sink.FlushAsync(TestContext.Current.CancellationToken).AsTask(); + Assert.False(flushing.IsCompleted); + + await sink.AppendAsync(Event(1), TestContext.Current.CancellationToken); + upstream.SetResult(); + await flushing; + + _ = Assert.Single(inner.Events); } [Fact] @@ -179,10 +187,6 @@ private static AuditEvent Event(long sequence, Guid? eventId = null) /// /// A batch-taking sink that holds its first batch open until a test releases it. /// - /// - /// It records the size of each batch, which is the only way to tell a queue that batches from one - /// that hands the store a row at a time. - /// private sealed class GatedBatchAuditSink : IAuditSinkPort { private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -205,6 +209,9 @@ public IReadOnlyList BatchSizes } } + /// Gets how many events this sink took, across every batch. + public int Written => BatchSizes.Sum(); + /// Lets the held batch complete, and every batch after it. public void Release() { diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/ApprovalCompilationTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/ApprovalCompilationTests.cs index 593d986f..36d4c301 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/ApprovalCompilationTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/ApprovalCompilationTests.cs @@ -12,8 +12,8 @@ namespace AgentCore.Application.Tests.Configuration.Compilation /// /// The approval: block reaching a compiled agent as UseToolApproval rules: an /// auto: entry lets its tool run with no request surfacing, anything else still asks, and - /// the layer adds no state keys — toolApprovalState persistence rides with the Phase 2 - /// round trip, when standing rules can exist. + /// the layer adds no state keys — toolApprovalState persistence waits until + /// standing rules can exist. /// public sealed class ApprovalCompilationTests { @@ -156,7 +156,7 @@ public void Compile_ApprovalSurface_KeepsTheQueueAndStandingKeys() CompiledAgent compiled = Compile(GatedToolYaml, client, () => { }, token); Assert.Equal( - ["_pendingApprovalRequests", "toolApprovalState"], + ["_pendingApprovalRequests", "agentcoreHeldApprovalAnswers", "toolApprovalState"], compiled.HarnessStateKeys.OrderBy(key => key)); } diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/ClarificationWiringBindingTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/ClarificationWiringBindingTests.cs index 0da4b5ad..513b47f3 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/ClarificationWiringBindingTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/ClarificationWiringBindingTests.cs @@ -3,13 +3,14 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Configuration.Compilation { @@ -211,7 +212,7 @@ protected override IAsyncEnumerable RunCoreStreamingAsync( /// /// A store that answers nothing while model is still in the scope, and names two models - /// once the probe has dropped it — the shape §8 exists to resolve. + /// once the probe has dropped it. /// private sealed class ScopedFakePort : IKnowledgeRetrievalPort { diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerConversationStampTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerConversationStampTests.cs index bea14b4f..a3f28d15 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerConversationStampTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerConversationStampTests.cs @@ -2,11 +2,11 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Llm; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Configuration.Compilation { diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerModelFacingTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerModelFacingTests.cs index 3e93bd9d..a9a19098 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerModelFacingTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/ConfigurationCompilerModelFacingTests.cs @@ -1,7 +1,6 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools.Binding; @@ -11,6 +10,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Configuration.Compilation { @@ -71,7 +71,7 @@ private static AIFunction DrawCard() return AIFunctionFactory.Create( (TurnInvocation? turn) => { - turn!.Sources!.Publish(new SourceReference { SourceId = "card-1", Kind = SourceKind.Document, Title = "hi", Origin = "test.draw" }); + turn!.Sources!.Publish(new SourceReference { SourceId = "card-1", Kind = SourceKind.Document, Title = "hi", Origin = "test.draw" }, turn.OuterCallId); return "drawn."; }, new AIFunctionFactoryOptions diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/FilesCompilationTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/FilesCompilationTests.cs index 61fc7d03..2244fdfc 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/FilesCompilationTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/FilesCompilationTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; @@ -12,6 +11,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Configuration.Compilation { diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/GraphNoMatchTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/GraphNoMatchTests.cs index 27cd96e3..d8be7e64 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/GraphNoMatchTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/GraphNoMatchTests.cs @@ -3,31 +3,21 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.State; using AgentCore.Application.Tests.Fakes; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Domain; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Configuration.Compilation { /// - /// Row 4 of the section 8.2 compile table, where no edge guard is true. + /// A graph where no edge guard is true. /// - /// - /// - /// A stage says onNoMatch: error, and a graph node has no such key. Measured on - /// Microsoft.Agents.AI.Workflows 1.17.0: a run whose outgoing guards are all false ends at - /// idle, InProcessRunner raises no exception, and the AsAIAgent() wrapper emits no - /// update. The caller therefore reads an empty run and no error, which is the silent graph failure - /// section 8.2 refuses to ship. - /// - /// - /// Every test here runs offline. There is no network conversation and no API key in this file. - /// - /// public sealed class GraphNoMatchTests { /// The text the one output node produces when the run reaches it. @@ -139,7 +129,7 @@ public async Task Row4_ANoMatchInsideAConversation_SpeaksTheFallbackAndTheConver TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Section 8.7, sixth row. The run throws, the turn speaks the fallback, and the conversation lives. + // The run throws, the turn speaks the fallback, and the conversation lives. // A live conversation therefore does not change: only a host that runs the compiled agent itself // reads the exception. Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs index e2b1ab85..8a3a630b 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs @@ -3,29 +3,17 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Configuration.Compilation { /// - /// Row 4 of the section 8.2 compile table, with a guarded edge. + /// A graph with a guarded edge. /// - /// - /// - /// Two rules pull apart here. T44 makes the compiled agent a process singleton, so one graph serves - /// every conversation and no code path compiles one for each conversation. A guarded edge reads the state of one - /// conversation, and each conversation owns its own state document. The graph-state wrapper files the turn's - /// snapshot on the run and a gate executor reads it back, so the compiled graph captures nothing - /// per conversation and concurrent conversations never share it. - /// - /// - /// Every test here runs offline. There is no network conversation and no API key in this file. - /// - /// public sealed class GuardedGraphEdgeTests { private const int FanOut = 26; @@ -37,7 +25,7 @@ public sealed class GuardedGraphEdgeTests private const string HandledReply = "HANDLED"; /// - /// One start node and two guarded exits. Check 5 of section 8.5 proves the two guards exclusive + /// One start node and two guarded exits. The load-time check proves the two guards exclusive /// over the state domain, so exactly one edge fires for each conversation. /// internal const string GuardedGraphYaml = @@ -79,7 +67,7 @@ public void AGuardedEdge_PassesEveryOneOfTheEightChecks() { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(GuardedGraphYaml); - // The defect this file closes: the document was valid and then failed to compile. Check 5 + // The document was valid and then failed to compile. The load-time check // even proves the two guards exclusive, so the load has nothing left to object to. ConfigurationValidationResult result = ConfigurationValidator.Evaluate(document); @@ -165,7 +153,7 @@ public async Task Rule16_TwentySixSimultaneousConversations_EachTakeTheirOwnEdge (bool Escalate, string ReplyText)[] results = await Task.WhenAll(conversations); - // Rule 16: one compiled agent, and no code path compiles one for each conversation. + // One compiled agent, and no code path compiles one for each conversation. Assert.Equal(1, harness.CompileCount); // No conversation read the state of another one. Thirteen went each way, and none went both. @@ -182,7 +170,7 @@ public async Task AGuardedEdgeWithNoFiledState_FailsLoudlyAndNeverAnswersFalse() using Harness harness = new(); // The run goes straight at the shared graph, with no turn filed on it anywhere. A guarded - // edge that quietly became unconditional is the silent graph failure section 8.2 refuses + // edge that quietly became unconditional is the silent graph failure the compiler refuses // to ship, so the wrapper throws instead. Exception? failure = await Record.ExceptionAsync(() => harness.Compiled.Agent.RunAsync( "hello", cancellationToken: TestContext.Current.CancellationToken)); @@ -231,7 +219,7 @@ public async Task Rule13_AGuardedGraph_YieldsItsFirstDeltaBeforeTheModelFinishes harness.ReleaseEscalatedReply(); - // The delta arrives before the run finishes, which is what rule 13 asks. It is the OUTPUT + // The delta arrives before the run finishes. It is the OUTPUT // node's word and not the start node's: the caller hears the answer, never the graph // deliberating its way to one, on the streaming path exactly as on the buffered one. Assert.Equal("ESCALATED", first); @@ -272,10 +260,6 @@ private static string Flatten(Exception exception) /// /// One compiled graph, wired the way the composition root wires it. /// - /// - /// The seam is the one AddAgentCore bind: the shared guard evaluator. Nothing per conversation - /// is captured: the turn's snapshot rides each run. - /// private sealed class Harness : IDisposable { private readonly ScriptedChatClient _router = new("ROUTED"); diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/MemoryCompilationTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/MemoryCompilationTests.cs index 3085f370..2dfccafa 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/MemoryCompilationTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/MemoryCompilationTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain; @@ -12,6 +11,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Configuration.Compilation { @@ -233,7 +233,7 @@ public async Task MidTurnEndConversation_DuringAFileMemoryWrite_StillDeletesTheF TurnResult turn = await session.RunTurnAsync("please remember this", TestContext.Current.CancellationToken); - Assert.False(turn.IsTerminal); + Assert.True(turn.IsTerminal); Assert.False(Directory.Exists(conversationFolder)); } diff --git a/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderKnowledgeTests.cs b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderKnowledgeTests.cs new file mode 100644 index 00000000..e9da9a1a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderKnowledgeTests.cs @@ -0,0 +1,222 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using Xunit; + +namespace AgentCore.Application.Tests.Configuration +{ + /// + /// The knowledge provider of a document binds: its store, its fields, its mapper and its links. + /// + public sealed class ConfigurationLoaderKnowledgeTests + { + [Fact] + public void AnEmptyKnowledgeBlock_FailsTheLoadForKindAndCollection() + { + // There is nothing to default them to. AgentCore knows no vendor and no collection name, so + // an empty block names no store at all rather than naming a conventional one. + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: {} + """; + + ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); + + Assert.Contains("kind", failure.Message, StringComparison.Ordinal); + Assert.Contains("collection", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AFieldsBlockNamingOneRole_LeavesEveryOtherRoleUnmapped() + { + // The whole point of the block: a role this document does not name is absent from the card. + // It is never filled in from a name AgentCore chose, because AgentCore chooses none. + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: + kind: qdrant + collection: pages + fields: { body: page_content } + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + KnowledgeProviderConfiguration knowledge = ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!; + + Assert.Equal("page_content", knowledge.Fields!.Body); + Assert.Null(knowledge.Fields.Id); + Assert.Null(knowledge.Fields.Lexical); + Assert.Null(knowledge.Fields.Source); + Assert.Null(knowledge.Fields.Locator); + Assert.Null(knowledge.Fields.Authority); + Assert.Null(knowledge.Scope.Template); + } + + [Fact] + public void ABodyFieldSetToNull_FailsTheLoad() + { + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: + kind: qdrant + collection: pages + fields: { body: null } + """; + + ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); + + Assert.Contains( + failure.Errors, + error => error.Pointer!.StartsWith("/providers/knowledge/fields", StringComparison.Ordinal)); + } + + [Fact] + public void AKnowledgeProviderThatWritesEveryField_BindsThem() + { + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: + kind: qdrant + endpoint: https://cluster.example.com:6334 + collection: manuals + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + KnowledgeProviderConfiguration knowledge = configuration.Providers!.Knowledge!; + Assert.Equal("qdrant", knowledge.Kind); + Assert.Equal("https://cluster.example.com:6334", knowledge.Endpoint); + Assert.Equal("manuals", knowledge.Collection); + } + + [Fact] + public void AKnowledgeProviderWithNoVector_BindsNullMeaningTheAnonymousVector() + { + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: { kind: qdrant, collection: manuals } + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + KnowledgeProviderConfiguration knowledge = ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!; + + Assert.Null(knowledge.Vector); + } + + [Fact] + public void AKnowledgeProviderNamingAMapper_BindsIt() + { + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: + kind: qdrant + collection: manuals + mapper: acme-catalog + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + Assert.Equal("acme-catalog", ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!.Mapper); + } + + [Fact] + public void NoLinksBlock_LeavesLinksNullAndTheFeatureOff() + { + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: { kind: qdrant, collection: manuals } + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + Assert.Null(ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!.Links); + } + + [Fact] + public void ALinksBlockWithoutLookup_DefaultsToFilter() + { + // filter is the only mode that works on any collection. uuid5 and direct derive the key. + const string document = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: + kind: qdrant + collection: manuals + links: { field: related } + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + KnowledgeLinksConfiguration? links = ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!.Links; + + Assert.NotNull(links); + Assert.Equal(KnowledgeLinkLookup.Filter, links!.Lookup); + Assert.Equal("related", links.Field); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderParsingTests.cs b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderParsingTests.cs new file mode 100644 index 00000000..4b7428fb --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderParsingTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using Xunit; + +namespace AgentCore.Application.Tests.Configuration +{ + /// + /// What the loader refuses in the YAML or JSON itself, before any section binds. + /// + public sealed class ConfigurationLoaderParsingTests + { + /// + /// A repeated key is a mistake in both formats. On the YAML path YamlDotNet's own loader rejects + /// two keys that are the same YAML node, before walks the mapping. + /// + [Fact] + public void ADuplicateKeyInYaml_FailsTheLoad() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + agents: + items: + - { id: only, instructions: "ok" } + """; + + ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); + + Assert.Contains(failure.Errors, error => error.Message.Contains("Duplicate key", StringComparison.Ordinal)); + } + + /// + /// Two keys that differ as YAML and agree as JSON are still one key in the document tree. + /// + [Fact] + public void TwoKeysThatDifferOnlyByTag_FailTheLoad() + { + const string document = """ + apiVersion: agentcore/v1 + !!str 1: first + 1: second + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); + + Assert.Equal(ConfigurationCheck.Syntax, failure.Check); + Assert.Contains(failure.Errors, error => error.Message.Contains("appears twice", StringComparison.Ordinal)); + } + + /// + /// A number no can hold is a defect in the document, not a crash. + /// + [Theory] + [InlineData("1e400")] + [InlineData("-1e400")] + public void ANumberTooLargeToHold_FailsTheLoad(string written) + { + ConfigurationLoadException failure = Assert.Throws( + () => ConfigurationLoader.LoadYaml($"apiVersion: agentcore/v1\nevaluation:\n sampleRate: {written}\n")); + + Assert.Equal(ConfigurationCheck.Syntax, failure.Check); + Assert.Contains(failure.Errors, error => error.Message.Contains("larger than a number can hold", StringComparison.Ordinal)); + } + + /// A number that underflows is zero, which JSON can write, so it loads. + [Fact] + public void ANumberTooSmallToHold_ReadsAsZero() + { + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml( + "apiVersion: agentcore/v1\nevaluation:\n sampleRate: 1e-400\nagents:\n items:\n - { id: only, instructions: \"ok\" }\nentries:\n main:\n agent: only\n"); + + Assert.Equal(0, configuration.Evaluation!.SampleRate); + } + + /// + /// A repeated key on the JSON path is rejected by ReadJson's own + /// setting before the + /// document ever reaches the shared reparse. This pins that behaviour too. + /// + [Fact] + public void ADuplicateKeyInJson_FailsTheLoad() + { + const string document = /*lang=json,strict*/ """ + { + "apiVersion": "agentcore/v1", + "agents": { "items": [{ "id": "only" }] }, + "agents": { "items": [{ "id": "only" }] } + } + """; + + ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadJson(document)); + + Assert.Contains(failure.Errors, error => error.Check == ConfigurationCheck.Syntax); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderSpokenLineTests.cs b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderSpokenLineTests.cs new file mode 100644 index 00000000..5d48768c --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderSpokenLineTests.cs @@ -0,0 +1,240 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using Xunit; + +namespace AgentCore.Application.Tests.Configuration +{ + /// + /// The lines a document has the caller hear, and its evaluation section, bind or take their defaults. + /// + public sealed class ConfigurationLoaderSpokenLineTests + { + private static readonly AgentCoreConfiguration Example = ConfigurationLoader.LoadYaml(ExampleDocument.Yaml); + + [Fact] + public void Example_BindsTheSpokenFallbackAndTheSampleRate() + { + // Both keys are optional, and the worked example writes both at their default. + Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, Example.FallbackReply); + Assert.NotNull(Example.Evaluation); + Assert.Equal(EvaluationConfiguration.DefaultSampleRate, Example.Evaluation!.SampleRate); + } + + [Fact] + public void Example_BindsTheJudgeModelReference() + { + Assert.NotNull(Example.Evaluation!.Judge); + Assert.Equal("judge", Example.Evaluation.Judge!.Ref); + Assert.Equal(0, Example.Evaluation.Judge.Temperature); + } + + [Fact] + public void AnEvaluationSectionWithNoJudge_LeavesTheReferenceNull() + { + const string document = """ + apiVersion: agentcore/v1 + evaluation: + sampleRate: 0.25 + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + Assert.Null(configuration.Evaluation!.Judge); + } + + [Fact] + public void AJudgeWithNoRef_FailsTheLoad() + { + const string document = """ + apiVersion: agentcore/v1 + evaluation: + judge: { temperature: 0 } + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); + + Assert.Contains(failure.Errors, error => error.Pointer == "/evaluation/judge"); + } + + [Fact] + public void ADocumentThatSetsBothTunableKeys_BindsThem() + { + const string document = """ + apiVersion: agentcore/v1 + fallbackReply: "One moment please. I will try that again." + evaluation: + sampleRate: 0.25 + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + Assert.Equal("One moment please. I will try that again.", configuration.FallbackReply); + Assert.Equal(0.25, configuration.Evaluation!.SampleRate); + } + + [Fact] + public void ADocumentThatOmitsBothTunableKeys_TakesTheDefaults() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + // A fallback is still spoken, and no turn is evaluated. + Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, configuration.FallbackReply); + Assert.Null(configuration.Evaluation); + } + + [Fact] + public void Example_BindsTheSpokenRefusal() + { + // The key is optional, and the worked example writes it at its default. + Assert.Equal(AgentCoreConfiguration.DefaultRefusalReply, Example.RefusalReply); + } + + [Fact] + public void ADocumentThatSetsTheRefusalReply_BindsIt() + { + const string document = """ + apiVersion: agentcore/v1 + refusalReply: "I am not able to answer that." + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + Assert.Equal("I am not able to answer that.", configuration.RefusalReply); + } + + [Fact] + public void ADocumentThatOmitsTheRefusalReply_TakesTheDefault() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + // A document written before the key existed behaves the same way. + Assert.Equal(AgentCoreConfiguration.DefaultRefusalReply, configuration.RefusalReply); + } + + [Fact] + public void ADocumentThatSetsOnlyOneSpokenLine_LeavesTheOtherAtItsDefault() + { + AgentCoreConfiguration withFallback = ConfigurationLoader.LoadYaml(""" + apiVersion: agentcore/v1 + fallbackReply: "One moment please. I will try that again." + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """); + AgentCoreConfiguration withRefusal = ConfigurationLoader.LoadYaml(""" + apiVersion: agentcore/v1 + refusalReply: "I am not able to answer that." + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """); + + Assert.Equal("One moment please. I will try that again.", withFallback.FallbackReply); + Assert.Equal(AgentCoreConfiguration.DefaultRefusalReply, withFallback.RefusalReply); + Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, withRefusal.FallbackReply); + Assert.Equal("I am not able to answer that.", withRefusal.RefusalReply); + } + + [Fact] + public void ADocumentThatSetsBothSpokenLines_BindsThemIndependently() + { + const string document = """ + apiVersion: agentcore/v1 + fallbackReply: "One moment please. I will try that again." + refusalReply: "I am not able to answer that." + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + Assert.Equal("One moment please. I will try that again.", configuration.FallbackReply); + Assert.Equal("I am not able to answer that.", configuration.RefusalReply); + } + + [Fact] + public void AnEvaluationSectionWithNoRate_TakesTheDefaultRate() + { + const string document = """ + apiVersion: agentcore/v1 + evaluation: {} + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); + + Assert.Equal(EvaluationConfiguration.DefaultSampleRate, configuration.Evaluation!.SampleRate); + } + + [Theory] + [InlineData("0", 0.0)] + [InlineData("1", 1.0)] + [InlineData("0.05", 0.05)] + public void ASampleRateInsideTheRange_Binds(string written, double expected) + { + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml( + $"apiVersion: agentcore/v1\nevaluation:\n sampleRate: {written}\nagents:\n items:\n - {{ id: only, instructions: \"ok\" }}\nentries:\n main:\n agent: only\n"); + + Assert.Equal(expected, configuration.Evaluation!.SampleRate); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderTests.cs b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderTests.cs index 9beda7c8..ee498585 100644 --- a/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/ConfigurationLoaderTests.cs @@ -6,7 +6,7 @@ namespace AgentCore.Application.Tests.Configuration { /// - /// The section 8.1 document parses, and every section binds. + /// The worked example document parses, and every section binds. /// public sealed class ConfigurationLoaderTests { @@ -157,502 +157,11 @@ public void Example_BindsProviders() Assert.Equal("cheap", Example.Providers.Llm[3].As); Assert.Equal(false, Example.Providers.Llm[3].WebSearch); Assert.Equal("telnyx-relay", Example.Providers.Speech!.Stt.Kind); - Assert.Equal("telnyx", Example.Providers.Telephony!.Kind); Assert.Equal("qdrant", Example.Providers.Knowledge!.Kind); Assert.Equal("https://qdrant.example.com:6334", Example.Providers.Knowledge.Endpoint); Assert.Equal("kb", Example.Providers.Knowledge.Collection); } - [Fact] - public void AnEmptyKnowledgeBlock_FailsTheLoadForKindAndCollection() - { - // There is nothing to default them to. AgentCore knows no vendor and no collection name, so - // an empty block names no store at all rather than naming a conventional one. - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: {} - """; - - ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); - - Assert.Contains("kind", failure.Message, StringComparison.Ordinal); - Assert.Contains("collection", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void AFieldsBlockNamingOneRole_LeavesEveryOtherRoleUnmapped() - { - // The whole point of the block: a role this document does not name is absent from the card. - // It is never filled in from a name AgentCore chose, because AgentCore chooses none. - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: - kind: qdrant - collection: pages - fields: { body: page_content } - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - KnowledgeProviderConfiguration knowledge = ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!; - - Assert.Equal("page_content", knowledge.Fields!.Body); - Assert.Null(knowledge.Fields.Id); - Assert.Null(knowledge.Fields.Lexical); - Assert.Null(knowledge.Fields.Source); - Assert.Null(knowledge.Fields.Locator); - Assert.Null(knowledge.Fields.Authority); - Assert.Null(knowledge.Scope.Template); - } - - [Fact] - public void ABodyFieldSetToNull_FailsTheLoad() - { - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: - kind: qdrant - collection: pages - fields: { body: null } - """; - - ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); - - Assert.Contains( - failure.Errors, - error => error.Pointer!.StartsWith("/providers/knowledge/fields", StringComparison.Ordinal)); - } - - [Fact] - public void AKnowledgeProviderThatWritesEveryField_BindsThem() - { - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: - kind: qdrant - endpoint: https://cluster.example.com:6334 - collection: manuals - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - KnowledgeProviderConfiguration knowledge = configuration.Providers!.Knowledge!; - Assert.Equal("qdrant", knowledge.Kind); - Assert.Equal("https://cluster.example.com:6334", knowledge.Endpoint); - Assert.Equal("manuals", knowledge.Collection); - } - - [Fact] - public void AKnowledgeProviderWithNoVector_BindsNullMeaningTheAnonymousVector() - { - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: { kind: qdrant, collection: manuals } - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - KnowledgeProviderConfiguration knowledge = ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!; - - Assert.Null(knowledge.Vector); - } - - [Fact] - public void AKnowledgeProviderNamingAMapper_BindsIt() - { - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: - kind: qdrant - collection: manuals - mapper: acme-catalog - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - Assert.Equal("acme-catalog", ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!.Mapper); - } - - [Fact] - public void Example_BindsTheSpokenFallbackAndTheSampleRate() - { - // Both keys are optional, and the worked example writes both at their default. - Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, Example.FallbackReply); - Assert.NotNull(Example.Evaluation); - Assert.Equal(EvaluationConfiguration.DefaultSampleRate, Example.Evaluation!.SampleRate); - } - - [Fact] - public void Example_BindsTheJudgeModelReference() - { - Assert.NotNull(Example.Evaluation!.Judge); - Assert.Equal("judge", Example.Evaluation.Judge!.Ref); - Assert.Equal(0, Example.Evaluation.Judge.Temperature); - } - - [Fact] - public void AnEvaluationSectionWithNoJudge_LeavesTheReferenceNull() - { - const string document = """ - apiVersion: agentcore/v1 - evaluation: - sampleRate: 0.25 - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - Assert.Null(configuration.Evaluation!.Judge); - } - - [Fact] - public void AJudgeWithNoRef_FailsTheLoad() - { - const string document = """ - apiVersion: agentcore/v1 - evaluation: - judge: { temperature: 0 } - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); - - Assert.Contains(failure.Errors, error => error.Pointer == "/evaluation/judge"); - } - - [Fact] - public void ADocumentThatSetsBothTunableKeys_BindsThem() - { - const string document = """ - apiVersion: agentcore/v1 - fallbackReply: "One moment please. I will try that again." - evaluation: - sampleRate: 0.25 - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - Assert.Equal("One moment please. I will try that again.", configuration.FallbackReply); - Assert.Equal(0.25, configuration.Evaluation!.SampleRate); - } - - [Fact] - public void ADocumentThatOmitsBothTunableKeys_TakesTheDefaults() - { - const string document = """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - // Section 8.7 still speaks a fallback, and T18 still evaluates no turn. - Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, configuration.FallbackReply); - Assert.Null(configuration.Evaluation); - } - - [Fact] - public void Example_BindsTheSpokenRefusal() - { - // The key is optional, and the worked example writes it at its default. - Assert.Equal(AgentCoreConfiguration.DefaultRefusalReply, Example.RefusalReply); - } - - [Fact] - public void ADocumentThatSetsTheRefusalReply_BindsIt() - { - const string document = """ - apiVersion: agentcore/v1 - refusalReply: "I am not able to answer that." - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - Assert.Equal("I am not able to answer that.", configuration.RefusalReply); - } - - [Fact] - public void ADocumentThatOmitsTheRefusalReply_TakesTheDefault() - { - const string document = """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - // A document written before the key existed behaves the same way. - Assert.Equal(AgentCoreConfiguration.DefaultRefusalReply, configuration.RefusalReply); - } - - [Fact] - public void ADocumentThatSetsOnlyOneSpokenLine_LeavesTheOtherAtItsDefault() - { - AgentCoreConfiguration withFallback = ConfigurationLoader.LoadYaml(""" - apiVersion: agentcore/v1 - fallbackReply: "One moment please. I will try that again." - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """); - AgentCoreConfiguration withRefusal = ConfigurationLoader.LoadYaml(""" - apiVersion: agentcore/v1 - refusalReply: "I am not able to answer that." - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """); - - Assert.Equal("One moment please. I will try that again.", withFallback.FallbackReply); - Assert.Equal(AgentCoreConfiguration.DefaultRefusalReply, withFallback.RefusalReply); - Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, withRefusal.FallbackReply); - Assert.Equal("I am not able to answer that.", withRefusal.RefusalReply); - } - - [Fact] - public void ADocumentThatSetsBothSpokenLines_BindsThemIndependently() - { - const string document = """ - apiVersion: agentcore/v1 - fallbackReply: "One moment please. I will try that again." - refusalReply: "I am not able to answer that." - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - Assert.Equal("One moment please. I will try that again.", configuration.FallbackReply); - Assert.Equal("I am not able to answer that.", configuration.RefusalReply); - } - - [Fact] - public void AnEvaluationSectionWithNoRate_TakesTheDefaultRate() - { - const string document = """ - apiVersion: agentcore/v1 - evaluation: {} - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(document); - - Assert.Equal(EvaluationConfiguration.DefaultSampleRate, configuration.Evaluation!.SampleRate); - } - - [Theory] - [InlineData("0", 0.0)] - [InlineData("1", 1.0)] - [InlineData("0.05", 0.05)] - public void ASampleRateInsideTheRange_Binds(string written, double expected) - { - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml( - $"apiVersion: agentcore/v1\nevaluation:\n sampleRate: {written}\nagents:\n items:\n - {{ id: only, instructions: \"ok\" }}\nentries:\n main:\n agent: only\n"); - - Assert.Equal(expected, configuration.Evaluation!.SampleRate); - } - - /// - /// A repeated key is a mistake in both formats. On the YAML path YamlDotNet's own loader rejects - /// two keys that are the same YAML node, before walks the mapping. - /// - [Fact] - public void ADuplicateKeyInYaml_FailsTheLoad() - { - const string document = """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - agents: - items: - - { id: only, instructions: "ok" } - """; - - ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); - - Assert.Contains(failure.Errors, error => error.Message.Contains("Duplicate key", StringComparison.Ordinal)); - } - - /// - /// Two keys that differ as YAML and agree as JSON are still one key in the document tree. - /// - /// - /// A tag makes two scalars different YAML nodes, so YamlDotNet keeps both — and both name the - /// property "1" once the mapping becomes a JsonObject. The check in - /// YamlToJson.ConvertMapping is what catches this, and it is the only thing that does: - /// without it the second value silently replaces the first. - /// - [Fact] - public void TwoKeysThatDifferOnlyByTag_FailTheLoad() - { - const string document = """ - apiVersion: agentcore/v1 - !!str 1: first - 1: second - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadYaml(document)); - - Assert.Equal(ConfigurationCheck.Syntax, failure.Check); - Assert.Contains(failure.Errors, error => error.Message.Contains("appears twice", StringComparison.Ordinal)); - } - - /// - /// A number no can hold is a defect in the document, not a crash. - /// - /// - /// It parses to an infinity, which JSON cannot write. The loader used to let the - /// ArgumentException that came of it straight out, past the - /// section 8.7 tells every caller to catch. - /// - [Theory] - [InlineData("1e400")] - [InlineData("-1e400")] - public void ANumberTooLargeToHold_FailsTheLoad(string written) - { - ConfigurationLoadException failure = Assert.Throws( - () => ConfigurationLoader.LoadYaml($"apiVersion: agentcore/v1\nevaluation:\n sampleRate: {written}\n")); - - Assert.Equal(ConfigurationCheck.Syntax, failure.Check); - Assert.Contains(failure.Errors, error => error.Message.Contains("larger than a number can hold", StringComparison.Ordinal)); - } - - /// A number that underflows is zero, which JSON can write, so it loads. - [Fact] - public void ANumberTooSmallToHold_ReadsAsZero() - { - AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml( - "apiVersion: agentcore/v1\nevaluation:\n sampleRate: 1e-400\nagents:\n items:\n - { id: only, instructions: \"ok\" }\nentries:\n main:\n agent: only\n"); - - Assert.Equal(0, configuration.Evaluation!.SampleRate); - } - - /// - /// A repeated key on the JSON path is rejected by ReadJson's own - /// setting before the - /// document ever reaches the shared reparse. This pins that behaviour too. - /// - [Fact] - public void ADuplicateKeyInJson_FailsTheLoad() - { - const string document = /*lang=json,strict*/ """ - { - "apiVersion": "agentcore/v1", - "agents": { "items": [{ "id": "only" }] }, - "agents": { "items": [{ "id": "only" }] } - } - """; - - ConfigurationLoadException failure = Assert.Throws(() => ConfigurationLoader.LoadJson(document)); - - Assert.Contains(failure.Errors, error => error.Check == ConfigurationCheck.Syntax); - } - [Fact] public void Load_AToolWithAModelRef_ReadsIt() { @@ -693,58 +202,6 @@ public void Load_AToolWithMaxRounds_ReadsIt() Assert.Equal(4, configuration.Tools[0].MaxRounds); } - [Fact] - public void NoLinksBlock_LeavesLinksNullAndTheFeatureOff() - { - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: { kind: qdrant, collection: manuals } - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - Assert.Null(ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!.Links); - } - - [Fact] - public void ALinksBlockWithoutLookup_DefaultsToFilter() - { - // filter is the only mode that works on any collection. uuid5 and direct derive the key. - const string document = """ - apiVersion: agentcore/v1 - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - knowledge: - kind: qdrant - collection: manuals - links: { field: related } - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - KnowledgeLinksConfiguration? links = ConfigurationLoader.LoadYaml(document).Providers!.Knowledge!.Links; - - Assert.NotNull(links); - Assert.Equal(KnowledgeLinkLookup.Filter, links!.Lookup); - Assert.Equal("related", links.Field); - } - [Fact] public void ShippedExampleFile_Loads() { diff --git a/tests/AgentCore.Application.Tests/Configuration/ConversationSchemaTests.cs b/tests/AgentCore.Application.Tests/Configuration/ConversationSchemaTests.cs index a7fb3509..3bae741a 100644 --- a/tests/AgentCore.Application.Tests/Configuration/ConversationSchemaTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/ConversationSchemaTests.cs @@ -7,12 +7,6 @@ namespace AgentCore.Application.Tests.Configuration /// /// providers.conversation names the vendor that carries the conversation, and the limits of its socket. /// - /// - /// The media plane is not providers.speech, which names recognition and synthesis one role - /// at a time, and it is not providers.telephony, which is conversation control. Section 8.2 asks a - /// document that writes a providers: section to name both the pipe and the ears, so the two - /// blocks are required together — and the speech block requires both of its own roles in turn. - /// public sealed class ConversationSchemaTests { [Fact] @@ -69,17 +63,37 @@ public void AMissingConversationBlockFailsTheLoad() Assert.Contains("conversation", failure.Message, StringComparison.Ordinal); } + // answerSeconds and the vendor-owned live: block. [Fact] - public void AMissingSpeechBlockFailsTheLoad() + public void TheAnswerDeadlineAndTheVendorBlockBind() + { + AgentCoreConfiguration configuration = Load(""" + providers: + conversation: + kind: openai-live + answerSeconds: 3 + live: + instructions: "Be brief." + voice: marin + """); + + ConversationProviderConfiguration conversation = configuration.Providers!.Conversation!; + Assert.Equal(3, conversation.AnswerSeconds); + Assert.Equal("Be brief.", conversation.Live.GetProperty("instructions").GetString()); + Assert.Equal("marin", conversation.Live.GetProperty("voice").GetString()); + } + + [Fact] + public void AnAnswerDeadlineOfZeroFailsTheLoad() { ConfigurationLoadException failure = Assert.Throws( () => Load(""" providers: - conversation: { kind: telnyx-relay } + conversation: { kind: openai-live, answerSeconds: 0 } """)); Assert.Equal(ConfigurationCheck.DocumentSchema, failure.Check); - Assert.Contains("speech", failure.Message, StringComparison.Ordinal); + Assert.Contains("answerSeconds", failure.Message, StringComparison.Ordinal); } [Fact] @@ -182,11 +196,6 @@ public void AnUnknownFieldUnderSpeechFailsTheLoad() /// Loads one providers: section under the smallest complete document header. /// The providers: section, written at the document's own margin. /// The loaded document. - /// - /// Check 1 of section 8.5 is the only check ConfigurationLoader.LoadYaml runs, and the - /// document root requires apiVersion, agents, and entries. Every other test - /// in this folder loads the same way. - /// private static AgentCoreConfiguration Load(string providers) { return ConfigurationLoader.LoadYaml( diff --git a/tests/AgentCore.Application.Tests/Configuration/ExampleDocument.cs b/tests/AgentCore.Application.Tests/Configuration/ExampleDocument.cs index 6764d66c..ddb56c54 100644 --- a/tests/AgentCore.Application.Tests/Configuration/ExampleDocument.cs +++ b/tests/AgentCore.Application.Tests/Configuration/ExampleDocument.cs @@ -1,34 +1,14 @@ namespace AgentCore.Application.Tests.Configuration { /// - /// The worked example of section 8.1, in both forms. + /// The worked example document, in both forms. /// - /// - /// The YAML is the document as the design writes it, plus the three optional keys section 8.1 does - /// not print: fallbackReply, refusalReply, and evaluation. All three hold their - /// default, and the shipped - /// config/example.yaml holds the same document. The JSON is the same document again, and it - /// was produced by a different YAML reader so that rule 17 of section 11 tests something real. - /// internal static class ExampleDocument { /// /// The last line of the providers: block, for a test that splices its own provider block /// in after it. /// - /// - /// - /// Three test files used to keep a private copy of the one-line providers.knowledge entry and - /// anchor on that. When the block grew to ten lines, every copy stopped matching, Replace - /// silently did nothing, and eighteen tests failed on a provider that had never been written - /// into the document at all. One anchor, owned by the document, cannot drift out of step with it. - /// - /// - /// It must stay the LAST line of providers:. A splice after any earlier line lands inside the - /// block that follows, which is a different failure again -- valid YAML, wrong nesting, and a - /// provider silently belonging to the wrong parent. - /// - /// public const string LastProviderLine = " citation: source-locator"; /// @@ -37,7 +17,7 @@ internal static class ExampleDocument public const string BlobsLine = " blobs: { kind: s3, endpoint: https://s3.us-east-005.backblazeb2.com, bucket: agentcore-files, region: us-east-005 }"; - /// The section 8.1 document as YAML. + /// The worked example document as YAML. public const string Yaml = """ apiVersion: agentcore/v1 @@ -171,7 +151,6 @@ internal static class ExampleDocument speech: # the ears and the mouth, named one role at a time stt: { kind: telnyx-relay } # recognition. Bundled here, so it matches conversation tts: { kind: telnyx-relay } # synthesis. Bundled here, so it matches conversation - telephony: { kind: telnyx } # dial, transfer, hang up. Not the pipe — that is conversation moderation: { kind: openai } # reads what the CALLER said, before the model runs embeddings: { kind: openai, model: text-embedding-3-small } blobs: { kind: s3, endpoint: https://s3.us-east-005.backblazeb2.com, bucket: agentcore-files, region: us-east-005 } @@ -208,468 +187,7 @@ internal static class ExampleDocument judge: { ref: judge, temperature: 0 } """; - /// The same document as JSON. - public const string Json = - /*lang=json,strict*/ - """ - { - "apiVersion": "agentcore/v1", - "fallbackReply": "I am sorry. I could not finish that. Please say it again.", - "refusalReply": "I am sorry. I cannot help with that request.", - "state": { - "callerAskedForHuman": { - "type": "boolean", - "default": false, - "writer": "extractor" - }, - "callerSaidGoodbye": { - "type": "boolean", - "default": false, - "writer": "extractor" - }, - "machineIdentified": { - "type": "boolean", - "default": false, - "writer": "extractor" - }, - "resolved": { - "type": "boolean", - "default": false, - "writer": "extractor" - }, - "orderStatus": { - "type": "string", - "writer": "tool", - "from": "lookup_order.status" - }, - "brand": { - "type": "string", - "writer": "extractor", - "description": "The brand of the caller's machine.", - "enum": [ - "sole", - "spirit" - ] - }, - "applies_to": { - "type": "string", - "writer": "extractor", - "description": "The model, as printed on the machine.", - "enum": [ - "f63", - "f65", - "f80" - ] - }, - "failedResolveTurns": { - "type": "integer", - "default": 0, - "writer": "counter", - "increment": { - "and": [ - { - "===": [ - { - "var": "stage" - }, - "resolve" - ] - }, - { - "!": { - "var": "resolved" - } - } - ] - } - } - }, - "extractor": { - "model": { - "ref": "fill" - }, - "when": "after_reply" - }, - "guards": { - "saidGoodbye": { - "var": "callerSaidGoodbye" - }, - "wantsHuman": { - "and": [ - { - "!": { - "var": "callerSaidGoodbye" - } - }, - { - "var": "callerAskedForHuman" - } - ] - }, - "identified": { - "and": [ - { - "!": { - "var": "callerSaidGoodbye" - } - }, - { - "!": { - "var": "callerAskedForHuman" - } - }, - { - "var": "machineIdentified" - } - ] - }, - "goodbyeOrFixed": { - "or": [ - { - "var": "callerSaidGoodbye" - }, - { - "and": [ - { - "!": { - "var": "callerAskedForHuman" - } - }, - { - "var": "resolved" - } - ] - } - ] - }, - "humanOrExhausted": { - "and": [ - { - "!": { - "var": "callerSaidGoodbye" - } - }, - { - "or": [ - { - "var": "callerAskedForHuman" - }, - { - "and": [ - { - "!": { - "var": "resolved" - } - }, - { - ">=": [ - { - "var": "failedResolveTurns" - }, - 3 - ] - } - ] - } - ] - } - ] - } - }, - "tools": [ - { - "id": "lookup_order", - "kind": "http", - "description": "Read one order by its identifier.", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": [ - "orderId" - ] - }, - "request": { - "method": "GET", - "url": "https://api.example.com/orders/{orderId}", - "headers": { - "Authorization": "Bearer ${secret:orders-api-key}" - } - } - }, - { - "id": "create_case", - "kind": "binding", - "binds": "CreateCase", - "description": "Open a service case for a human agent.", - "parameters": { - "type": "object", - "properties": { - "summary": { - "type": "string" - } - }, - "required": [ - "summary" - ] - } - }, - { - "id": "search", - "kind": "builtin", - "uses": "web.search" - }, - { - "id": "publish", - "kind": "builtin", - "uses": "file.publish" - } - ], - "agents": { - "defaults": { - "model": { - "ref": "reply", - "temperature": 0.3 - }, - "instructions": "\n", - "knowledge": { - "mode": "prefetch", - "limit": 5, - "citations": false - } - }, - "items": [ - { - "id": "greeter", - "instructions": "", - "tools": [] - }, - { - "id": "identifier", - "instructions": "", - "tools": [ - "lookup_order" - ] - }, - { - "id": "resolver", - "instructions": "", - "tools": [] - }, - { - "id": "escalator", - "instructions": "", - "tools": [ - "create_case" - ] - }, - { - "id": "closer", - "instructions": "", - "tools": [] - }, - { - "id": "analyst", - "instructions": "", - "tools": [ - "lookup_order", - "create_case", - "search", - "publish" - ], - "knowledge": { - "mode": "tool", - "limit": 8, - "citations": true, - "scoped": false - } - }, - { - "id": "webchat", - "instructions": "", - "tools": [ - "lookup_order" - ], - "knowledge": { - "mode": "tool", - "citations": false - } - } - ] - }, - "entries": { - "phone": { - "fallbackReply": "Sorry — say it again.", - "policy": { - "initial": "greeting", - "stages": [ - { - "id": "greeting", - "agent": "greeter", - "to": [ - { - "stage": "identify" - } - ] - }, - { - "id": "identify", - "agent": "identifier", - "to": [ - { - "stage": "close", - "when": "saidGoodbye" - }, - { - "stage": "escalate", - "when": "wantsHuman" - }, - { - "stage": "resolve", - "when": "identified" - } - ] - }, - { - "id": "resolve", - "agent": "resolver", - "to": [ - { - "stage": "close", - "when": "goodbyeOrFixed" - }, - { - "stage": "escalate", - "when": "humanOrExhausted" - } - ] - }, - { - "id": "escalate", - "agent": "escalator", - "to": [ - { - "stage": "close" - } - ] - }, - { - "id": "close", - "agent": "closer", - "terminal": true - } - ] - } - }, - "chat": { - "agent": "webchat", - "refusalReply": "Sorry — I can't help with that here." - } - }, - "providers": { - "llm": [ - { - "kind": "openai", - "model": "gpt-4.1-mini", - "as": "reply" - }, - { - "kind": "openai", - "model": "gpt-5.4-nano", - "as": "fill" - }, - { - "kind": "openai", - "model": "gpt-4.1", - "as": "judge" - }, - { - "kind": "openai", - "model": "gpt-4.1-nano", - "as": "cheap", - "webSearch": false - } - ], - "conversation": { - "kind": "telnyx-relay" - }, - "speech": { - "stt": { - "kind": "telnyx-relay" - }, - "tts": { - "kind": "telnyx-relay" - } - }, - "telephony": { - "kind": "telnyx" - }, - "moderation": { - "kind": "openai" - }, - "embeddings": { - "kind": "openai", - "model": "text-embedding-3-small" - }, - "blobs": { - "kind": "s3", - "endpoint": "https://s3.us-east-005.backblazeb2.com", - "bucket": "agentcore-files", - "region": "us-east-005" - }, - "knowledge": { - "kind": "qdrant", - "endpoint": "https://qdrant.example.com:6334", - "collection": "kb", - "vector": "dense", - "fields": { - "id": "card_id", - "body": "body", - "lexical": "text", - "source": "source.ref", - "locator": "source.locator", - "authority": "authority" - }, - "scope": { - "template": "facets.{key}", - "wildcard": { - "value": "*", - "facets": [ - "brand", - "applies_to" - ] - }, - "fromState": [ - "brand", - "applies_to" - ], - "filterable": [ - { - "key": "applies_to", - "description": "The machine the question is about, as one tag, such as ct900 or f63-2019." - } - ] - }, - "links": { - "field": "see_also", - "lookup": "uuid5", - "prefix": "kb:" - }, - "analyzer": "none", - "citation": "source-locator" - } - }, - "evaluation": { - "sampleRate": 0, - "judge": { - "ref": "judge", - "temperature": 0 - } - } - } - """; + /// The same document as JSON, kept in two halves so that no file passes the size limit. + public const string Json = ExampleDocumentStateJson.Text + "\n" + ExampleDocumentAgentsJson.Text; } } diff --git a/tests/AgentCore.Application.Tests/Configuration/ExampleDocumentAgentsJson.cs b/tests/AgentCore.Application.Tests/Configuration/ExampleDocumentAgentsJson.cs new file mode 100644 index 00000000..c62778f1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/ExampleDocumentAgentsJson.cs @@ -0,0 +1,252 @@ +namespace AgentCore.Application.Tests.Configuration +{ + /// + /// The second half of : the agents, the entries, the providers and the evaluation section. + /// + internal static class ExampleDocumentAgentsJson + { + /// The JSON text. It is not a document on its own. + internal const string Text = + """ + "agents": { + "defaults": { + "model": { + "ref": "reply", + "temperature": 0.3 + }, + "instructions": "\n", + "knowledge": { + "mode": "prefetch", + "limit": 5, + "citations": false + } + }, + "items": [ + { + "id": "greeter", + "instructions": "", + "tools": [] + }, + { + "id": "identifier", + "instructions": "", + "tools": [ + "lookup_order" + ] + }, + { + "id": "resolver", + "instructions": "", + "tools": [] + }, + { + "id": "escalator", + "instructions": "", + "tools": [ + "create_case" + ] + }, + { + "id": "closer", + "instructions": "", + "tools": [] + }, + { + "id": "analyst", + "instructions": "", + "tools": [ + "lookup_order", + "create_case", + "search", + "publish" + ], + "knowledge": { + "mode": "tool", + "limit": 8, + "citations": true, + "scoped": false + } + }, + { + "id": "webchat", + "instructions": "", + "tools": [ + "lookup_order" + ], + "knowledge": { + "mode": "tool", + "citations": false + } + } + ] + }, + "entries": { + "phone": { + "fallbackReply": "Sorry — say it again.", + "policy": { + "initial": "greeting", + "stages": [ + { + "id": "greeting", + "agent": "greeter", + "to": [ + { + "stage": "identify" + } + ] + }, + { + "id": "identify", + "agent": "identifier", + "to": [ + { + "stage": "close", + "when": "saidGoodbye" + }, + { + "stage": "escalate", + "when": "wantsHuman" + }, + { + "stage": "resolve", + "when": "identified" + } + ] + }, + { + "id": "resolve", + "agent": "resolver", + "to": [ + { + "stage": "close", + "when": "goodbyeOrFixed" + }, + { + "stage": "escalate", + "when": "humanOrExhausted" + } + ] + }, + { + "id": "escalate", + "agent": "escalator", + "to": [ + { + "stage": "close" + } + ] + }, + { + "id": "close", + "agent": "closer", + "terminal": true + } + ] + } + }, + "chat": { + "agent": "webchat", + "refusalReply": "Sorry — I can't help with that here." + } + }, + "providers": { + "llm": [ + { + "kind": "openai", + "model": "gpt-4.1-mini", + "as": "reply" + }, + { + "kind": "openai", + "model": "gpt-5.4-nano", + "as": "fill" + }, + { + "kind": "openai", + "model": "gpt-4.1", + "as": "judge" + }, + { + "kind": "openai", + "model": "gpt-4.1-nano", + "as": "cheap", + "webSearch": false + } + ], + "conversation": { + "kind": "telnyx-relay" + }, + "speech": { + "stt": { + "kind": "telnyx-relay" + }, + "tts": { + "kind": "telnyx-relay" + } + }, + "moderation": { + "kind": "openai" + }, + "embeddings": { + "kind": "openai", + "model": "text-embedding-3-small" + }, + "blobs": { + "kind": "s3", + "endpoint": "https://s3.us-east-005.backblazeb2.com", + "bucket": "agentcore-files", + "region": "us-east-005" + }, + "knowledge": { + "kind": "qdrant", + "endpoint": "https://qdrant.example.com:6334", + "collection": "kb", + "vector": "dense", + "fields": { + "id": "card_id", + "body": "body", + "lexical": "text", + "source": "source.ref", + "locator": "source.locator", + "authority": "authority" + }, + "scope": { + "template": "facets.{key}", + "wildcard": { + "value": "*", + "facets": [ + "brand", + "applies_to" + ] + }, + "fromState": [ + "brand", + "applies_to" + ], + "filterable": [ + { + "key": "applies_to", + "description": "The machine the question is about, as one tag, such as ct900 or f63-2019." + } + ] + }, + "links": { + "field": "see_also", + "lookup": "uuid5", + "prefix": "kb:" + }, + "analyzer": "none", + "citation": "source-locator" + } + }, + "evaluation": { + "sampleRate": 0, + "judge": { + "ref": "judge", + "temperature": 0 + } + } + } + """; + } +} diff --git a/tests/AgentCore.Application.Tests/Configuration/ExampleDocumentStateJson.cs b/tests/AgentCore.Application.Tests/Configuration/ExampleDocumentStateJson.cs new file mode 100644 index 00000000..288a21f1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/ExampleDocumentStateJson.cs @@ -0,0 +1,229 @@ +namespace AgentCore.Application.Tests.Configuration +{ + /// + /// The first half of : the header keys, state, the extractor, the guards and the tools. + /// + internal static class ExampleDocumentStateJson + { + /// The JSON text. It is not a document on its own. + internal const string Text = + """ + { + "apiVersion": "agentcore/v1", + "fallbackReply": "I am sorry. I could not finish that. Please say it again.", + "refusalReply": "I am sorry. I cannot help with that request.", + "state": { + "callerAskedForHuman": { + "type": "boolean", + "default": false, + "writer": "extractor" + }, + "callerSaidGoodbye": { + "type": "boolean", + "default": false, + "writer": "extractor" + }, + "machineIdentified": { + "type": "boolean", + "default": false, + "writer": "extractor" + }, + "resolved": { + "type": "boolean", + "default": false, + "writer": "extractor" + }, + "orderStatus": { + "type": "string", + "writer": "tool", + "from": "lookup_order.status" + }, + "brand": { + "type": "string", + "writer": "extractor", + "description": "The brand of the caller's machine.", + "enum": [ + "sole", + "spirit" + ] + }, + "applies_to": { + "type": "string", + "writer": "extractor", + "description": "The model, as printed on the machine.", + "enum": [ + "f63", + "f65", + "f80" + ] + }, + "failedResolveTurns": { + "type": "integer", + "default": 0, + "writer": "counter", + "increment": { + "and": [ + { + "===": [ + { + "var": "stage" + }, + "resolve" + ] + }, + { + "!": { + "var": "resolved" + } + } + ] + } + } + }, + "extractor": { + "model": { + "ref": "fill" + }, + "when": "after_reply" + }, + "guards": { + "saidGoodbye": { + "var": "callerSaidGoodbye" + }, + "wantsHuman": { + "and": [ + { + "!": { + "var": "callerSaidGoodbye" + } + }, + { + "var": "callerAskedForHuman" + } + ] + }, + "identified": { + "and": [ + { + "!": { + "var": "callerSaidGoodbye" + } + }, + { + "!": { + "var": "callerAskedForHuman" + } + }, + { + "var": "machineIdentified" + } + ] + }, + "goodbyeOrFixed": { + "or": [ + { + "var": "callerSaidGoodbye" + }, + { + "and": [ + { + "!": { + "var": "callerAskedForHuman" + } + }, + { + "var": "resolved" + } + ] + } + ] + }, + "humanOrExhausted": { + "and": [ + { + "!": { + "var": "callerSaidGoodbye" + } + }, + { + "or": [ + { + "var": "callerAskedForHuman" + }, + { + "and": [ + { + "!": { + "var": "resolved" + } + }, + { + ">=": [ + { + "var": "failedResolveTurns" + }, + 3 + ] + } + ] + } + ] + } + ] + } + }, + "tools": [ + { + "id": "lookup_order", + "kind": "http", + "description": "Read one order by its identifier.", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": [ + "orderId" + ] + }, + "request": { + "method": "GET", + "url": "https://api.example.com/orders/{orderId}", + "headers": { + "Authorization": "Bearer ${secret:orders-api-key}" + } + } + }, + { + "id": "create_case", + "kind": "binding", + "binds": "CreateCase", + "description": "Open a service case for a human agent.", + "parameters": { + "type": "object", + "properties": { + "summary": { + "type": "string" + } + }, + "required": [ + "summary" + ] + } + }, + { + "id": "search", + "kind": "builtin", + "uses": "web.search" + }, + { + "id": "publish", + "kind": "builtin", + "uses": "file.publish" + } + ], + """; + } +} diff --git a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreStateTests.cs b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreStateTests.cs new file mode 100644 index 00000000..398dcbdd --- /dev/null +++ b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreStateTests.cs @@ -0,0 +1,30 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Application.Tests.Conversation +{ + /// The state facts of , against the in-memory store. + public sealed class InMemoryConversationStoreStateTests + { + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public Task SaveState_LevelWithTheStoredState_ReplacesIt() + { + return ConversationStoreStateFacts.AStateLevelWithTheStoredOneReplacesIt(new InMemoryConversationStore(), Token); + } + + [Fact] + public Task SaveState_BehindTheStoredState_IsDropped() + { + return ConversationStoreStateFacts.AStateBehindTheStoredOneIsDropped(new InMemoryConversationStore(), Token); + } + + [Fact] + public Task SaveState_OfAConversationWithNoRow_LeavesItAlone() + { + return ConversationStoreStateFacts.AConversationWithNoRowIsLeftAlone(new InMemoryConversationStore(), Token); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs index 6eed58d7..6909c0a3 100644 --- a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs @@ -7,7 +7,7 @@ namespace AgentCore.Application.Tests.Conversation { - /// Store 0, kept in this process. + /// The conversation store, kept in this process. public sealed class InMemoryConversationStoreTests { private static CancellationToken Token => TestContext.Current.CancellationToken; diff --git a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTranscriptTests.cs b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTranscriptTests.cs index 1e0224f3..f2f18198 100644 --- a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTranscriptTests.cs @@ -7,7 +7,7 @@ namespace AgentCore.Application.Tests.Conversation { - /// The words half of store 0, now that one store holds both halves. + /// The words half of the conversation store, now that one store holds both halves. public sealed class InMemoryConversationStoreTranscriptTests { private static CancellationToken Token => TestContext.Current.CancellationToken; diff --git a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreWindowTests.cs b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreWindowTests.cs index 103595aa..2bd66e65 100644 --- a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreWindowTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreWindowTests.cs @@ -5,7 +5,7 @@ namespace AgentCore.Application.Tests.Conversation { - /// Reading store 1 a window of turns at a time, newest turns first, every turn whole. + /// Reading the message store a window of turns at a time, newest turns first, every turn whole. public sealed class InMemoryConversationStoreWindowTests { private static CancellationToken Token => TestContext.Current.CancellationToken; diff --git a/tests/AgentCore.Application.Tests/Diagnostics/DiagnosticsFakes.cs b/tests/AgentCore.Application.Tests/Diagnostics/DiagnosticsFakes.cs index 1aefdc95..6baab3cd 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/DiagnosticsFakes.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/DiagnosticsFakes.cs @@ -71,17 +71,16 @@ public void Log( /// /// A sink that holds every append open until a test releases it. /// - /// - /// Section 7 measures a durable insert at 13 ms p50 and 32 ms p99, against 91 nanoseconds to - /// enqueue, so the turn must never wait for the row. This sink turns that into a question a test - /// answers without a stopwatch: the turn finishes while every append is still open. - /// internal sealed class BlockingAuditSink : IAuditSinkPort { private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Lock _lock = new(); private readonly List _events = []; + /// Completes once the first append is inside the sink. + public Task Entered => _entered.Task; + /// Gets the events this sink took, in the order they arrived. public IReadOnlyList Events { @@ -107,6 +106,7 @@ public async ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken canc _events.Add(auditEvent); } + _ = _entered.TrySetResult(); await _gate.Task.ConfigureAwait(false); } } @@ -114,10 +114,6 @@ public async ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken canc /// /// A sink that refuses every event. /// - /// - /// Audit is a record of the conversation and never a part of it, so a sink that fails must be reported and - /// the turn must go on. - /// internal sealed class ThrowingAuditSink : IAuditSinkPort { /// The message every refusal carries. diff --git a/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryHarness.cs b/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryHarness.cs new file mode 100644 index 00000000..b6c8ec2f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryHarness.cs @@ -0,0 +1,113 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using System.Diagnostics; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Diagnostics +{ + /// The span listeners and factory builders that shares across its tests. + internal static class LibraryOpenTelemetryHarness + { + /// The default source name OpenTelemetryChatClient (Microsoft.Extensions.AI 10.8.3) + /// resolves to when a caller names none. Read out of the restored assembly; see + /// ConfigurationCompiler.WithToolFailureAuditing. + internal const string ChatSourceName = "Experimental.Microsoft.Extensions.AI"; + + /// The default source name OpenTelemetryAgent (Microsoft.Agents.AI 1.17.0) resolves + /// to when a caller names none. Read out of the restored assembly; see + /// ConfigurationCompiler.BuildAgents. + internal const string AgentSourceName = "Experimental.Microsoft.Agents.AI"; + + /// + /// Whether one span is a per-round model call and not the relabeled invoke_agent span. + /// + internal static bool IsChatRoundSpan(Activity span) + { + return span.OperationName.StartsWith("chat", StringComparison.Ordinal) + && !span.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal); + } + + /// Subscribes to the two library sources this task turns on. + internal static ActivityListener ListenToLibrarySources(List spans) + { + return ListenTo(spans, ChatSourceName, AgentSourceName); + } + + /// Subscribes to the named sources, collecting every span each one closes. + internal static ActivityListener ListenTo(List spans, params string[] sources) + { + ActivityListener listener = new() + { + ShouldListenTo = source => Array.Exists(sources, name => string.Equals(source.Name, name, StringComparison.Ordinal)), + Sample = (ref _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + lock (spans) + { + spans.Add(activity); + } + }, + }; + + ActivitySource.AddActivityListener(listener); + return listener; + } + + /// Copies what the listener has collected so far. See the sibling helper in + /// TurnObservabilityTests for why a reader must not enumerate the live list. + internal static List Snapshot(List spans) + { + lock (spans) + { + return [.. spans]; + } + } + + internal static ConversationSessionFactory Build(string yaml, IChatClient client, Func? tools) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + FakeChatClientFactory factory = new(client); + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(factory) + { + Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), + })["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, factory), + timeProvider: null, + logger: null, + hooks: BuiltInHooks.Create(new InMemoryAuditSink())); + } + + /// Builds a factory for a document that declares an extractor, scripting the two models + /// apart on the ref names the document uses. + internal static ConversationSessionFactory BuildWithExtractor(string yaml, IChatClient reply, IChatClient fill) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + RoutingChatClientFactory factory = new RoutingChatClientFactory(reply).Route("fill", fill); + + CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(factory))["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, factory), + timeProvider: null, + logger: null, + hooks: BuiltInHooks.Create(new InMemoryAuditSink())); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs index 30ad68fe..7c23f657 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs @@ -1,54 +1,24 @@ -using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; -using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.Diagnostics; using Xunit; +using AgentCore.Application.Runtime.Session; +using static AgentCore.Application.Tests.Diagnostics.LibraryOpenTelemetryHarness; namespace AgentCore.Application.Tests.Diagnostics { /// - /// Task 6a: Microsoft.Extensions.AI's and Microsoft.Agents.AI's own GenAI instrumentation, switched on - /// for the first time. AgentCoreTelemetry.cs asserted both libraries "already emit" their spans - /// and metrics; before this task nothing ever called UseOpenTelemetry, so nothing did. + /// Microsoft.Extensions.AI's and Microsoft.Agents.AI's own GenAI instrumentation, switched on for every + /// compiled agent. /// - /// - /// - /// Every test here proves something an or a direct - /// GetService probe observed, not merely that the wiring compiles. - /// 's WithToolFailureAuditing and Resolve remarks - /// explain WHERE the instrumentation sits and WHY; this file is the proof those remarks describe what - /// the compiled pipeline actually does. - /// - /// - /// Every agent id and conversation id here is unique per test run (a fresh ), because an - /// subscribes to the whole process and another test class may run - /// beside this one on the same two library sources. - /// - /// public sealed class LibraryOpenTelemetryTests { - /// The default source name OpenTelemetryChatClient (Microsoft.Extensions.AI 10.8.3) - /// resolves to when a caller names none. Read out of the restored assembly; see - /// ConfigurationCompiler.WithToolFailureAuditing. - private const string ChatSourceName = "Experimental.Microsoft.Extensions.AI"; - - /// The default source name OpenTelemetryAgent (Microsoft.Agents.AI 1.17.0) resolves - /// to when a caller names none. Read out of the restored assembly; see - /// ConfigurationCompiler.BuildAgents. - private const string AgentSourceName = "Experimental.Microsoft.Agents.AI"; - - // ------------------------------------------------------------------------------------------- - // The ordering fix: execute_tool nests under invoke_agent, not beside it. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AToolCallingTurn_NestsExecuteToolUnderInvokeAgentAndNotBesideIt() { @@ -95,10 +65,10 @@ public async Task AToolCallingTurn_NestsExecuteToolUnderInvokeAgentAndNotBesideI Activity executeTool = Assert.Single( underInvokeAgent, span => span.OperationName.StartsWith("execute_tool", StringComparison.Ordinal)); - // The actual proof the ordering fix worked: execute_tool's parent IS invoke_agent, by object + // The proof: execute_tool's parent IS invoke_agent, by object // identity and by W3C parent id — not a sibling of it, and not nested inside the whole - // tool-calling loop as one "chat" span the way the broken ordering produced. See the "Chat - // telemetry is wired here" remark on WithToolFailureAuditing for why the fix makes this true: + // tool-calling loop as one "chat" span. See the "Chat + // telemetry is wired here" remark on WithToolFailureAuditing for why this holds: // the per-round chat span (source "Experimental.Microsoft.Extensions.AI") closes before the // tool runs, so Activity.Current has reverted to invoke_agent by the time execute_tool opens. Assert.Same(invokeAgent, executeTool.Parent); @@ -110,9 +80,6 @@ public async Task AToolCallingTurn_NestsExecuteToolUnderInvokeAgentAndNotBesideI Assert.Equal(2, chatRounds.Count); } - // ------------------------------------------------------------------------------------------- - // Exactly once: every compiled agent is wrapped for OpenTelemetry, and none is wrapped twice. - // ------------------------------------------------------------------------------------------- [Fact] public async Task EveryCompiledAgent_IsInstrumentedExactlyOnce() { @@ -138,7 +105,7 @@ public async Task EveryCompiledAgent_IsInstrumentedExactlyOnce() // Reachable structurally, with no turn run at all: ConfigurationCompiler.Resolve wraps the // agent it just built, once, immediately before it caches it in the dictionary every later - // lookup (row 2's stage lookup, and the delegation tool's ResolveInner) reads back out of. A + // lookup (the stage lookup, and the delegation tool's ResolveInner) reads back out of. A // second wrap would require Resolve to reach the "var built = new ChatClientAgent(...)" branch // twice for one id, and the early "agents.TryGetValue" return above it is what this test would // catch failing to hold. @@ -174,11 +141,6 @@ public async Task EveryCompiledAgent_IsInstrumentedExactlyOnce() mine, span => span.DisplayName.StartsWith("invoke_agent " + specialistId, StringComparison.Ordinal)); } - // ------------------------------------------------------------------------------------------- - // The absolute constraint: EnableSensitiveData stays false on both the agent-level and the - // chat-level instrumentation, regardless of what OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT - // says in the environment this test happens to run in. - // ------------------------------------------------------------------------------------------- [Fact] public void EveryCompiledAgent_KeepsSensitiveDataCaptureOffOnBothLayers() { @@ -214,23 +176,9 @@ public void EveryCompiledAgent_KeepsSensitiveDataCaptureOffOnBothLayers() Assert.False(chatClient.EnableSensitiveData); } - // ------------------------------------------------------------------------------------------- - // gen_ai.conversation.id: the parent span carries it, and the library children do not. - // ------------------------------------------------------------------------------------------- - /// /// The conversation id reaches a trace once, from AgentCoreTelemetry.StartTurn. /// - /// - /// Naming the conversation on ChatOptions.ConversationId would put the attribute on the library's - /// children as well, and it is the one thing that must not be done to get it. To - /// ChatClientAgent a conversation id means the SERVICE keeps the history, so it answers by - /// ignoring the agent's own ChatHistoryProvider for the whole run — store 1, silently - /// gone. Keeping it then costs a per-run override of the provider and - /// ThrowOnChatHistoryProviderConflict = false on every compiled agent. The attribute is - /// not worth a disabled safety check: a reader that wants the conversation id of a child span walks up - /// the trace to agentcore.turn. - /// [Fact] public async Task ASingleAgentTurn_CarriesTheConversationIdOnTheTurnSpanAndOnNeitherLibrarySpan() { @@ -275,21 +223,9 @@ public async Task ASingleAgentTurn_CarriesTheConversationIdOnTheTurnSpanAndOnNei Assert.Null(chat.GetTagItem("gen_ai.conversation.id")); } - // ------------------------------------------------------------------------------------------- - // The extractor is a model call too, and a model call the trace does not show is a model call - // nobody pays attention to until the bill arrives. - // ------------------------------------------------------------------------------------------- - /// /// One turn makes two model calls, and both of them emit a chat span. /// - /// - /// ConversationSessionTranscriptTests.ATurn_MakesTwoModelCalls_TheReplyAndTheExtractor proves the two conversations - /// happen. This proves both are instrumented. Until ConversationSessionFactory.CreateExtractor - /// wrapped the client it resolves, only the reply had a span: the extractor's duration, its token - /// usage, and its failures reached no exporter at all, so a two-turn conversation reported half the model - /// calls it made and roughly a third less spend than it cost. - /// [Fact] public async Task ATurnWithAnExtractor_EmitsAChatSpanForTheExtractorCallAndNotOnlyForTheReply() { @@ -375,98 +311,5 @@ public async Task ATurnWithAnExtractor_EmitsAChatSpanForTheExtractorCallAndNotOn Assert.Equal(ChatSourceName, extractorChat.Source.Name); Assert.Null(extractorChat.GetTagItem("gen_ai.input.messages")); } - - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- - /// - /// Whether one span is a per-round model call and not the relabeled invoke_agent span. - /// - /// - /// OpenTelemetryAgent opens its own invoke_agent span through an internal - /// OpenTelemetryChatClient instance, so that span's own Activity.OperationName is - /// also "chat" — only its DisplayName is rewritten. A filter on OperationName - /// alone therefore matches both the real per-round chat span this predicate looks for and the - /// invoke_agent span beside it; excluding the renamed DisplayName is what tells them apart. - /// - private static bool IsChatRoundSpan(Activity span) - { - return span.OperationName.StartsWith("chat", StringComparison.Ordinal) - && !span.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal); - } - - /// Subscribes to the two library sources this task turns on. - private static ActivityListener ListenToLibrarySources(List spans) - { - return ListenTo(spans, ChatSourceName, AgentSourceName); - } - - /// Subscribes to the named sources, collecting every span each one closes. - private static ActivityListener ListenTo(List spans, params string[] sources) - { - ActivityListener listener = new() - { - ShouldListenTo = source => Array.Exists(sources, name => string.Equals(source.Name, name, StringComparison.Ordinal)), - Sample = (ref _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => - { - lock (spans) - { - spans.Add(activity); - } - }, - }; - - ActivitySource.AddActivityListener(listener); - return listener; - } - - /// Copies what the listener has collected so far. See the sibling helper in - /// TurnObservabilityTests for why a reader must not enumerate the live list. - private static List Snapshot(List spans) - { - lock (spans) - { - return [.. spans]; - } - } - - private static ConversationSessionFactory Build(string yaml, IChatClient client, Func? tools) - { - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - FakeChatClientFactory factory = new(client); - - CompiledAgent compiled = ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(factory) - { - Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), - })["main"]; - - return new ConversationSessionFactory( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, factory), - timeProvider: null, - logger: null, - ConversationObservers.Standard(new InMemoryAuditSink(), logger: null)); - } - /// Builds a factory for a document that declares an extractor, scripting the two models - /// apart on the ref names the document uses. - private static ConversationSessionFactory BuildWithExtractor(string yaml, IChatClient reply, IChatClient fill) - { - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - RoutingChatClientFactory factory = new RoutingChatClientFactory(reply).Route("fill", fill); - - CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(factory))["main"]; - - return new ConversationSessionFactory( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, factory), - timeProvider: null, - logger: null, - ConversationObservers.Standard(new InMemoryAuditSink(), logger: null)); - } } } diff --git a/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs deleted file mode 100644 index 465507af..00000000 --- a/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace AgentCore.Application.Tests.Diagnostics -{ - /// - /// The "log once" rows of section 8.7, written from a fact instead of from the turn loop. - /// - /// - /// Moving the conversation sites behind the hook must change neither the text an operator greps for nor how - /// often it appears, so these tests pin the event id, the level, and the fields of each of the five - /// lines this observer writes. They also pin the kinds that write nothing here: a normal conversation is - /// recorded by the chain of D23 and a log is not, and a fact whose exception is logged at its own catch - /// site (row six, and a store 1 write or read that failed) is not logged twice. - /// - public sealed class LoggingConversationObserverTests - { - private const string ConversationId = "conversation-1"; - - /// The ids the source-generated methods carry. They are unchanged by the hook. - private const int ExtractionFailedEventId = 1; - private const int EmptyReplyEventId = 3; - private const int PromptRefusedEventId = 6; - private const int ModerationUnavailableEventId = 7; - private const int StateRestorePartialEventId = 13; - - /// The kinds this observer writes no line for. - public static TheoryData QuietKinds => - [ - ConversationEventKind.ConversationStarted, - ConversationEventKind.TurnCompleted, - ConversationEventKind.ReplyInterrupted, - ConversationEventKind.ConversationEnded, - - // Logged, but not here: the line is written where the exception still exists (ConversationGate, - // ConversationTranscriptLedger, ConversationTurnCommit), with the object itself and not only its - // message. A line from this observer too would double it. - ConversationEventKind.TranscriptWriteFailed, - ConversationEventKind.ToolFailed, - - // The quietest of them all: counted, and not logged anywhere. - ConversationEventKind.ModerationClean, - ]; - - [Fact] - public async Task AQuietRun_IsAWarningNamingTheTurn() - { - RecordingLogger logger = new(); - - await Observe(logger, Event(ConversationEventKind.EmptyReply, turnIndex: 3)); - - LogLine line = Assert.Single(logger.Of(EmptyReplyEventId)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Equal( - "Turn 3 of conversation conversation-1 returned an empty reply, so it spoke the fallback. " - + "The run reached 40 tool rounds, or the model answered nothing.", - line.Message); - } - - [Fact] - public async Task AnExtractorThatProducedNothing_IsAWarningAndNeverAnError() - { - RecordingLogger logger = new(); - - // Row two: the slots stay unchanged and the conversation continues. - await Observe( - logger, - Event(ConversationEventKind.ExtractionFailed, turnIndex: 0, ConversationEventPayloadKeys.Reason, "it timed out.")); - - LogLine line = Assert.Single(logger.Of(ExtractionFailedEventId)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Equal( - "The extractor of conversation conversation-1 produced nothing for turn 0: it timed out. " - + "The slots stay unchanged and the conversation continues.", - line.Message); - } - - [Fact] - public async Task ARefusedPrompt_ReportsTheCategoriesAndNeverTheWords() - { - RecordingLogger logger = new(); - - await Observe( - logger, - Event( - ConversationEventKind.PromptFlagged, - turnIndex: 1, - AuditPayloadKeys.ModerationCategories, - "harassment,violence")); - - LogLine line = Assert.Single(logger.Of(PromptRefusedEventId)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Equal( - "Moderation flagged turn 1 of conversation conversation-1 for harassment,violence, " - + "so the agent refused it and spoke the refusal line.", - line.Message); - } - - [Fact] - public async Task AModerationEndpointThatDidNotAnswer_IsAWarningBecauseTheVendorIsNotThisLibrary() - { - RecordingLogger logger = new(); - - await Observe( - logger, - Event( - ConversationEventKind.ModerationUnavailable, - turnIndex: 4, - ConversationEventPayloadKeys.Reason, - "it did not answer within 500 ms.")); - - LogLine line = Assert.Single(logger.Of(ModerationUnavailableEventId)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Equal( - "Moderation did not answer for turn 4 of conversation conversation-1 (it did not answer within 500 ms.). " - + "The turn ran unchecked, because moderation fails open.", - line.Message); - } - - [Fact] - public async Task APartialStateRestore_IsAWarningNamingTheConversationAndWhatItLost() - { - RecordingLogger logger = new(); - - // No turn index: the conversation is still opening. It is documented as logged, and until this line - // existed it was not — a document change that cost every resumed conversation its stage produced no - // line and no metric, and six tests of the restore itself passed straight over the silence. - await Observe( - logger, - Event( - ConversationEventKind.StateRestorePartial, - turnIndex: null, - ConversationEventPayloadKeys.Reason, - "the document no longer declares the slot 'model'.")); - - LogLine line = Assert.Single(logger.Of(StateRestorePartialEventId)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Equal( - "Conversation conversation-1 could not restore part of its stored state: the document no longer declares " - + "the slot 'model'. The conversation resumes without that part.", - line.Message); - } - - [Theory] - [MemberData(nameof(QuietKinds))] - public async Task AKindTheChainRecords_WritesNoLine(ConversationEventKind kind) - { - RecordingLogger logger = new(); - - await Observe(logger, Event(kind, turnIndex: 0)); - - Assert.Empty(logger.Lines); - } - - [Fact] - public async Task NoLogger_IsStillAnObserver() - { - // The library never throws for want of one. - ValueTask pending = new LoggingConversationObserver().OnConversationEventAsync( - Event(ConversationEventKind.EmptyReply, turnIndex: 0), - TestContext.Current.CancellationToken); - - await pending; - Assert.True(pending.IsCompletedSuccessfully); - } - - [Fact] - public async Task NoEvent_IsRefused() - { - _ = await Assert.ThrowsAsync(async () => - await new LoggingConversationObserver() - .OnConversationEventAsync(null!, TestContext.Current.CancellationToken)); - } - - private static async Task Observe(ILogger logger, ConversationEvent conversationEvent) - { - LoggingConversationObserver observer = new(logger); - - // The observer never waits, so this always completes on this thread. It is awaited anyway, - // because the port says nothing about which of the two it does. - await observer.OnConversationEventAsync(conversationEvent, TestContext.Current.CancellationToken); - } - - private static ConversationEvent Event(ConversationEventKind kind, int? turnIndex, string? key = null, string? detail = null) - { - Dictionary payload = new(StringComparer.Ordinal); - if (key is not null && detail is not null) - { - payload[key] = detail; - } - - return new ConversationEvent - { - ConversationId = ConversationId, - Kind = kind, - OccurredAt = DateTimeOffset.UnixEpoch, - TurnIndex = turnIndex, - Payload = payload, - }; - } - } -} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs deleted file mode 100644 index e4906750..00000000 --- a/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System.Collections.ObjectModel; -using System.Diagnostics.Metrics; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Xunit; - -namespace AgentCore.Application.Tests.Diagnostics -{ - /// - /// The three counters of section 8.6, incremented from a fact instead of from the turn loop. - /// - public sealed class TelemetryConversationObserverTests - { - private const string FailureInstrument = "agentcore.turn.failures"; - private const string ModerationInstrument = "agentcore.moderation.verdicts"; - private const string AuditInstrument = "agentcore.audit.events"; - - private const string FailureKey = "agentcore.failure.kind"; - private const string ModerationKey = "agentcore.moderation.outcome"; - private const string AuditKey = "agentcore.audit.kind"; - - /// Each failure kind, beside the value section 8.7 gives its row. - public static TheoryData Failures => - new() - { - { ConversationEventKind.ToolFailed, "tool" }, - { ConversationEventKind.EmptyReply, "empty_reply" }, - { ConversationEventKind.ExtractionFailed, "extraction" }, - { ConversationEventKind.RunFaulted, "run" }, - }; - - /// Each moderation verdict, beside the value an operator alerts on. - public static TheoryData Verdicts => - new() - { - { ConversationEventKind.PromptFlagged, "flagged" }, - { ConversationEventKind.ModerationClean, "clean" }, - { ConversationEventKind.ModerationUnavailable, "unavailable" }, - }; - - /// Each stored kind, beside the wire token the chain of D23 hashes. - public static TheoryData StoredKinds => - new() - { - { ConversationEventKind.ConversationStarted, "conversation.started" }, - { ConversationEventKind.PromptFlagged, "prompt.flagged" }, - { ConversationEventKind.ToolFailed, "tool.failed" }, - { ConversationEventKind.TurnCompleted, "turn.completed" }, - { ConversationEventKind.ReplyInterrupted, "reply.interrupted" }, - { ConversationEventKind.ConversationEnded, "conversation.ended" }, - }; - - [Theory] - [MemberData(nameof(Failures))] - public async Task AFailureRow_IsCountedUnderItsOwnKind(ConversationEventKind kind, string expected) - { - List measured = await MeasureAsync(kind); - - Assert.Contains(new Reading(FailureInstrument, FailureKey, expected), measured); - } - - [Theory] - [MemberData(nameof(Verdicts))] - public async Task AModerationVerdict_IsCountedUnderItsOwnOutcome(ConversationEventKind kind, string expected) - { - List measured = await MeasureAsync(kind); - - Assert.Contains(new Reading(ModerationInstrument, ModerationKey, expected), measured); - } - - [Theory] - [MemberData(nameof(StoredKinds))] - public async Task AStoredKind_IsCountedUnderTheTokenTheChainWrites(ConversationEventKind kind, string expected) - { - List measured = await MeasureAsync(kind); - - Assert.Contains(new Reading(AuditInstrument, AuditKey, expected), measured); - } - - [Fact] - public async Task NoEvent_IsRefused() - { - _ = await Assert.ThrowsAsync(async () => - await new TelemetryConversationObserver().OnConversationEventAsync(null!, TestContext.Current.CancellationToken)); - } - - // A turn whose tool fails four times raises four per-call facts, each with a tool call id, and one - // turn-level fact with none. agentcore.turn.failures promises one count per failed turn, so only the - // turn-level fact moves it (audit finding E5, docs/probes/r2/audit/AUDIT.md). - [Fact] - public async Task APerCallToolFailure_MovesNothing_OnlyTheTurnLevelFactDoes() - { - List perCall = await MeasureAsync( - ConversationEventKind.ToolFailed, - new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.ToolCallId] = "call-1" }); - - Assert.DoesNotContain(new Reading(FailureInstrument, FailureKey, "tool"), perCall); - - List turnLevel = await MeasureAsync(ConversationEventKind.ToolFailed); - - Assert.Contains(new Reading(FailureInstrument, FailureKey, "tool"), turnLevel); - } - - /// Hands one fact to the observer and reads back what the meter saw. - /// What happened. - /// The fact's payload, or empty for a kind that carries none in this suite. - /// Every attribute of every measurement, as instrument, key, and value. - private static async Task> MeasureAsync( - ConversationEventKind kind, IReadOnlyDictionary? payload = null) - { - List readings = []; - using MeterListener listener = new(); - - listener.InstrumentPublished = (instrument, active) => - { - if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) - { - active.EnableMeasurementEvents(instrument); - } - }; - - listener.SetMeasurementEventCallback((instrument, _, tags, _) => - { - KeyValuePair[] copy = tags.ToArray(); - lock (readings) - { - readings.AddRange(copy.Select(tag => - new Reading(instrument.Name, tag.Key, tag.Value?.ToString() ?? string.Empty))); - } - }); - - listener.Start(); - - await new TelemetryConversationObserver().OnConversationEventAsync( - new ConversationEvent - { - ConversationId = "conversation-1", - Kind = kind, - OccurredAt = DateTimeOffset.UnixEpoch, - EventId = Guid.CreateVersion7(), - TurnIndex = 0, - Payload = payload ?? ReadOnlyDictionary.Empty, - }, - TestContext.Current.CancellationToken); - - listener.Dispose(); - - lock (readings) - { - return [.. readings]; - } - } - - /// One attribute of one measurement. - /// The instrument that took it. - /// The attribute key. - /// The attribute value. - private sealed record Reading(string Instrument, string Key, string Value); - } -} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs index 7df926fe..806ef1e6 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs @@ -2,17 +2,17 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using Microsoft.Extensions.Logging; using Xunit; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Diagnostics.TurnObservabilityHarness; namespace AgentCore.Application.Tests.Diagnostics { /// - /// The rows of section 8.7 that say "log once": a failed extraction, an empty reply, the fourth tool failure, and a + /// The cases that log once: a failed extraction, an empty reply, the fourth tool failure, and a /// guard that throws. /// public sealed class TurnLogOnceTests @@ -26,8 +26,9 @@ public async Task AFailedExtraction_IsLoggedOnceForTheTurnAndTheConversationCont ConversationSession session = Build(PolicyYaml, reply, fill, logger: logger).Create("conversation-x"); TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); - // Row two of section 8.7: leave the slots unchanged, log once for the turn, and continue. + // A failed extraction leaves the slots unchanged, logs once for the turn, and continues. LogLine line = Assert.Single(logger.Of(1)); Assert.Equal(LogLevel.Warning, line.Level); Assert.Contains("conversation-x", line.Message, StringComparison.Ordinal); @@ -44,6 +45,7 @@ public async Task AnEmptyReply_IsLoggedOnce() ConversationSession session = Build(PolicyYaml, reply, fill, logger: logger).Create("conversation-x"); _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); LogLine line = Assert.Single(logger.Of(3)); Assert.Equal(LogLevel.Warning, line.Level); @@ -60,17 +62,18 @@ public async Task TheFourthConsecutiveToolFailure_IsLoggedOnce() ConversationSession session = Build(ToolYaml, reply, null, new ThrowingToolBuilder().Create, logger: logger).Create("conversation-x"); _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); - // Row six of section 8.7. The turn spoke the fallback, and the conversation is still alive. + // The turn spoke the fallback, and the conversation is still alive. // // ONE line, and the turn spent four tool calls to get here. Each of those four is a row in - // the chain of D23, which is where a record of a conversation belongs; the log gets the turn-level + // the audit chain, which is where a record of a conversation belongs; the log gets the turn-level // fact alone. LogLine line = Assert.Single(logger.Of(2)); Assert.Equal(LogLevel.Error, line.Level); // The message names the turn and never the fault: the fault's message and stack trace ride - // the Exception object instead (owner ruling: type in spans, full error in logs), so a span + // the Exception object instead (spans carry the type, logs carry the full error), so a span // never has to repeat what the log already owns. Assert.DoesNotContain(ThrowingToolBuilder.Message, line.Message, StringComparison.Ordinal); Assert.NotNull(line.Exception); diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs index 8afa88d7..742a6de7 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs @@ -1,20 +1,20 @@ using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using System.Diagnostics.Metrics; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Diagnostics.TurnObservabilityHarness; namespace AgentCore.Application.Tests.Diagnostics { /// - /// The metrics a turn reports, and the attributes they may carry. See T61. + /// The metrics a turn reports, and the attributes they may carry. /// public sealed class TurnMetricsTests { - /// The only attribute keys any metric of this library may carry. See T61. + /// The only attribute keys any metric of this library may carry. private static readonly string[] PermittedMetricKeys = [ "agentcore.turn.outcome", @@ -22,7 +22,7 @@ public sealed class TurnMetricsTests "agentcore.audit.kind", // Three values: clean, flagged, unavailable. Three series for each replica, against the - // 10,000 ceiling of item 12. No conversation id rides on it, so T61 holds. + // 10,000 ceiling. No conversation id rides on it. "agentcore.moderation.outcome", ]; @@ -36,7 +36,7 @@ public async Task NoMetricAttribute_EverCarriesTheConversationId() // The .NET default is cumulative temporality, so one conversation id on a metric attribute is one // permanent series. A day of conversations would then be a day of permanent series, and the free - // tier binds at 10,000. See D26 and T61. + // tier binds at 10,000. Assert.DoesNotContain(tags, tag => string.Equals(tag.Value as string, conversationId, StringComparison.Ordinal)); } @@ -48,7 +48,7 @@ public async Task EveryMetricAttribute_ComesFromAClosedSet() // The listener really saw the instruments of this library. Assert.NotEmpty(tags); - // T61 again, and this is the half that catches the next attribute somebody adds. Every key + // This half catches the next attribute somebody adds. Every key // below takes a handful of values the library writes itself. A key that is not on this list // has not been costed, so the build refuses it. Assert.All(tags, tag => Assert.Contains(tag.Key, PermittedMetricKeys, StringComparer.Ordinal)); @@ -120,6 +120,7 @@ public async Task ATurn_RecordsItsDurationOnceWithTheOutcome() _ = await session.RunTurnAsync("hi", token); _ = await session.RunTurnAsync("still there?", token); _ = await session.RunTurnAsync("goodbye", token); + await session.FlushNoticesAsync(); listener.Dispose(); return tags; diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs index 2382bf43..ff5022d5 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs @@ -5,13 +5,14 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Diagnostics; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Diagnostics { @@ -103,8 +104,7 @@ internal static ConversationSessionFactory Build( IAuditSinkPort? auditSink = null, ILogger? logger = null) { - // ConversationObservers.Standard requires a sink. A test that does not read its events gets a fresh - // in-memory one. + // The built-in hooks require a sink. A test that does not read its events gets a fresh in-memory one. IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); @@ -127,7 +127,7 @@ internal static ConversationSessionFactory Build( ConversationSessionFactory.CreateExtractor(compiled, chatClients), timeProvider, logger, - ConversationObservers.Standard(sink, logger)); + hooks: BuiltInHooks.Create(sink, logger)); } } } diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs index ed600dc7..c43e056b 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs @@ -1,10 +1,10 @@ using AgentCore.TestSupport; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using Microsoft.Extensions.AI; using System.Diagnostics; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Diagnostics.TurnObservabilityHarness; namespace AgentCore.Application.Tests.Diagnostics @@ -24,7 +24,7 @@ public async Task ATurn_IsOneSpanThatCarriesTheConversationIdAsAGenAiAttribute() Assert.Equal(AgentCoreTelemetry.TurnActivityName, span.OperationName); - // Item 7 asks for gen_ai.* attributes on the trace, and gen_ai.conversation.id is the + // gen_ai.conversation.id is the // convention's own name for the conversation a request belongs to. A conversation is that // conversation, and a span attribute costs no series. Assert.Equal(conversationId, span.GetTagItem("gen_ai.conversation.id")); @@ -53,10 +53,10 @@ public async Task AFailedTurn_MarksItsSpanWithTheReasonOfSectionEightSeven() Assert.Equal("failed", span.GetTagItem("agentcore.turn.outcome")); Assert.Equal(ActivityStatusCode.Error, span.Status); - // The span carries only the closed failure-kind token, never ConversationSession.EmptyReplyReason's + // The span carries only the closed failure-kind token, never TurnFailureReasons.EmptyReply's // own text: nothing threw here, so there is no exception type to report, and the fixed reason - // stays off the span the same way a raw exception message would (owner ruling: type in spans, - // full error in logs). + // stays off the span the same way a raw exception message would (spans carry the type, + // logs carry the full error). Assert.Equal(AgentCoreTelemetry.FailureEmptyReply, span.StatusDescription); Assert.Equal(AgentCoreTelemetry.FailureEmptyReply, span.GetTagItem("error.type")); } diff --git a/tests/AgentCore.Application.Tests/Fakes/NewestWordsToolChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/NewestWordsToolChatClient.cs new file mode 100644 index 00000000..afd53323 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/NewestWordsToolChatClient.cs @@ -0,0 +1,78 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// + /// Calls the first offered tool, once, when the newest user message holds , and answers + /// otherwise. A reply to the words in waits for . + /// + /// The words that ask for the tool. + /// What every other request is answered. + internal sealed class NewestWordsToolChatClient(string cue, string answer = "ok") : IChatClient + { + /// Gets every request this client read, in call order. + public List> Requests { get; } = []; + + /// Gets or sets the user words whose reply waits for , or . + public string? Held { get; set; } + + /// Completes once the reply to was asked for. + public TaskCompletionSource Holding { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Lets the reply to go. + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List request = [.. messages]; + lock (Requests) + { + Requests.Add(request); + } + + await Task.Yield(); + + string id = Guid.NewGuid().ToString("N"); + int newest = request.FindLastIndex(message => message.Role == ChatRole.User); + string words = newest < 0 ? string.Empty : request[newest].Text; + bool answered = request.Skip(newest + 1).Any(message => message.Contents.OfType().Any()); + if (!answered && words.Contains(cue, StringComparison.Ordinal) && options?.Tools?.OfType().FirstOrDefault() is { } tool) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_" + id, tool.Name)]) { MessageId = id }; + yield break; + } + + if (words == Held) + { + _ = Holding.TrySetResult(); + await Release.Task.WaitAsync(cancellationToken); + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, answer) { MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/ParallelToolChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/ParallelToolChatClient.cs new file mode 100644 index 00000000..fe136647 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/ParallelToolChatClient.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// + /// A model that makes every given call at once, in one answer, while the transcript holds no tool result; every + /// other request is answered with text. + /// + /// The text answer. + /// The tool name and call id of each call, in order. + internal sealed class ParallelToolChatClient(string reply, params (string Tool, string CallId)[] calls) : IChatClient + { + private int _calls; + + /// Gets how many requests reached the model. + public int Calls => Volatile.Read(ref _calls); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Interlocked.Increment(ref _calls); + bool answered = messages.Any(message => message.Contents.OfType().Any()); + await Task.Yield(); + + string id = Guid.NewGuid().ToString("N"); + if (!answered) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [.. calls.Select(call => (AIContent)new FunctionCallContent(call.CallId, call.Tool, new Dictionary()))]) + { + ResponseId = id, + MessageId = id, + }; + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, reply) { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/RecordingBlobStore.cs b/tests/AgentCore.Application.Tests/Fakes/RecordingBlobStore.cs index ac538c47..816236e6 100644 --- a/tests/AgentCore.Application.Tests/Fakes/RecordingBlobStore.cs +++ b/tests/AgentCore.Application.Tests/Fakes/RecordingBlobStore.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using AgentCore.Application.Blobs; using AgentCore.Application.Ports; @@ -6,7 +7,8 @@ namespace AgentCore.Application.Tests.Fakes /// An in-memory that keeps every write, so a test reads what was stored. internal sealed class RecordingBlobStore : IBlobStore { - private readonly Dictionary<(string Owner, string Name), (string MediaType, byte[] Bytes)> _blobs = []; + // Concurrent: graph participants of one turn may store at the same time. + private readonly ConcurrentDictionary<(string Owner, string Name), (string MediaType, byte[] Bytes)> _blobs = []; /// Gets every blob stored, keyed by owner and name, in no particular order. public IReadOnlyDictionary<(string Owner, string Name), (string MediaType, byte[] Bytes)> Blobs => _blobs; @@ -43,7 +45,7 @@ public ValueTask DeleteByOwnerAsync(string ownerId, CancellationToken cancellati { foreach ((string Owner, string Name) key in _blobs.Keys.Where(key => key.Owner == ownerId).ToList()) { - _ = _blobs.Remove(key); + _ = _blobs.TryRemove(key, out _); } return ValueTask.CompletedTask; diff --git a/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs index b13ced2a..201002a5 100644 --- a/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs +++ b/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs @@ -4,10 +4,6 @@ namespace AgentCore.Application.Tests.Fakes { /// Answers each request in turn, and keeps what it was asked. - /// - /// A streamed request is answered in one piece. is what a fact about the prompt reads: - /// one role-prefixed line per message, in the order the run sent them. - /// internal sealed class RequestRecordingChatClient(params string[] replies) : IChatClient { private readonly string[] _replies = replies; @@ -27,7 +23,7 @@ public Task GetResponseAsync( Requests.Add([.. messages.Select(message => $"{message.Role}:{message.Text}")]); Instructions.Add(options?.Instructions); return Task.FromResult( - new ChatResponse(new ChatMessage(ChatRole.Assistant, _replies[Requests.Count - 1]))); + new ChatResponse(new ChatMessage(ChatRole.Assistant, _replies[Requests.Count - 1]) { MessageId = $"m{Requests.Count}" })); } public async IAsyncEnumerable GetStreamingResponseAsync( diff --git a/tests/AgentCore.Application.Tests/Fakes/ToolCallingChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/ToolCallingChatClient.cs index e02d7f1f..f713e0e1 100644 --- a/tests/AgentCore.Application.Tests/Fakes/ToolCallingChatClient.cs +++ b/tests/AgentCore.Application.Tests/Fakes/ToolCallingChatClient.cs @@ -6,20 +6,7 @@ namespace AgentCore.Application.Tests.Fakes /// /// A deterministic offline model that calls the first tool it is offered, once, then answers. /// - /// - /// - /// A delegating agent needs a model that actually emits a tool call, and - /// only emits text. This client emits one - /// when the request carries a tool and the transcript holds no - /// tool result yet. Every other request answers with text, so the inner agent of a - /// kind: agent tool, which is offered no tool, always replies. - /// - /// - /// The one call and the "no result yet" test together keep the run finite. Nothing here depends on - /// the 40-iteration cap of section 8.7. - /// - /// - internal sealed class ToolCallingChatClient(string reply, Dictionary? arguments = null) : IChatClient + internal sealed class ToolCallingChatClient(string reply, Dictionary? arguments = null, bool everyRun = false) : IChatClient { private const string ConversationId = "conversation_1"; @@ -56,7 +43,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync( lock (Prompts) { - foreach (ChatMessage? message in transcript) + int lastUser = everyRun ? transcript.FindLastIndex(message => message.Role == ChatRole.User) : -1; + foreach (ChatMessage? message in transcript.Skip(lastUser + 1)) { foreach (AIContent content in message.Contents) { diff --git a/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs index 6e4a8a82..1c78716d 100644 --- a/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs +++ b/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs @@ -4,8 +4,7 @@ namespace AgentCore.Application.Tests.Fakes { /// - /// A model that answers each call from a script: text in fragments, one tool call, or a fault. Ported from the - /// design probes' Script (docs/probes/maf-native-engine/ProviderOwnsWritesProbes.cs). + /// A model that answers each call from a script: text in fragments, one tool call, or a fault. /// internal sealed class TurnScriptChatClient : IChatClient { @@ -51,6 +50,18 @@ public static TurnScriptChatClient ToolThenText(string tool, params string[] fra return new([(tool, false, []), (null, false, fragments)]); } + /// Calls each tool in order, one per call, then answers with text. + public static TurnScriptChatClient ToolsThenText(IReadOnlyList tools, params string[] fragments) + { + return new([.. tools.Select(tool => ((string?)tool, false, Array.Empty())), (null, false, fragments)]); + } + + /// Calls the same tool on every call, never answering with text. + public static TurnScriptChatClient ToolForever(string tool) + { + return new([(tool, false, [])]); + } + /// Calls one tool, then throws. public static TurnScriptChatClient ToolThenFault(string tool) { diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs new file mode 100644 index 00000000..e6bfc26c --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs @@ -0,0 +1,109 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Sessions; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// A person's approval answer is raised when a turn takes it in, by whichever door, and never when it is only built. + public sealed class ApprovalAnswerNoticeTests + { + // ToolCallingChatClient gives every call this id. + private const string ModelCallId = "conversation_1"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The human path names the function call id too, so the two notices of one call join. + [Fact] + public async Task AHumansAnswerIsNamed() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [notices]); + + TurnResult asked = await session.RunTurnAsync("send it", Ct); + ChatMessage? answer = session.TryCreateApprovalAnswer(asked.Approvals[0].RequestId, approved: true); + _ = await session.RunTurnMessageAsync(answer!, Ct); + await session.FlushNoticesAsync(); + + Assert.Equal( + [(ApprovalState.Asked, ApprovalBy.Human, ModelCallId), (ApprovalState.Approved, ApprovalBy.Human, ModelCallId)], + notices.Of().Select(changed => (changed.State, changed.By, changed.CallId))); + } + + // A refused call is a refusal, not an undeclared tool: one Blocked notice. + [Fact] + public async Task AHumansDenialBlocksTheCall() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [notices]); + + TurnResult asked = await session.RunTurnAsync("send it", Ct); + _ = await session.RunTurnMessageAsync(session.TryCreateApprovalAnswer(asked.Approvals[0].RequestId, approved: false)!, Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(0, tools.Sent); + ToolCalled called = Assert.Single(notices.Of()); + Assert.Equal((ModelCallId, ToolOutcome.Blocked), (called.CallId, called.Outcome)); + Assert.Equal(ApprovalState.Denied, notices.Of()[^1].State); + } + + [Fact] + public async Task ABuiltAnswerWhoseTurnIsRefusedRaisesNothing() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [notices]); + + TurnResult asked = await session.RunTurnAsync("send it", Ct); + ChatMessage answer = session.TryCreateApprovalAnswer(asked.Approvals[0].RequestId, approved: true)!; + await session.DisposeAsync(); + _ = await Assert.ThrowsAnyAsync(() => session.RunTurnMessageAsync(answer, Ct)); + await session.FlushNoticesAsync(); + + Assert.Equal(0, tools.Sent); + Assert.Equal(ApprovalState.Asked, Assert.Single(notices.Of()).State); + } + + // AgentCoreAgent takes the caller's own ToolApprovalResponseContent straight into the run; no answer is built. + [Fact] + public async Task AnAnswerPassedStraightToTheAgentIsRaisedOnce() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + CompiledAgent compiled = HookSessions.Compile(ApprovalTools.GatedYaml, tools.Model, [notices], tools: tools.Tool)["main"]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + using InMemoryConversationSessions sessions = new( + new Dictionary(StringComparer.Ordinal) { ["main"] = factory }, + TimeSpan.FromMinutes(30), + TimeProvider.System); + AgentCoreAgent agent = new(sessions, "main"); + AgentSession session = await agent.CreateSessionAsync(Ct); + + List first = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("send it", session, cancellationToken: Ct)) + { + first.Add(update); + } + + ToolApprovalRequestContent request = first.SelectMany(update => update.Contents).OfType().Single(); + _ = await agent.RunAsync(new ChatMessage(ChatRole.User, [request.CreateResponse(true)]), session, cancellationToken: Ct); + _ = await notices.WaitForAsync(completed => completed.Scope.TurnIndex == 1).WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Assert.Equal(1, tools.Sent); + Assert.Equal( + [(ApprovalState.Asked, ModelCallId), (ApprovalState.Approved, ModelCallId)], + notices.Of().Select(changed => (changed.State, changed.CallId))); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalDenialRoundsTests.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalDenialRoundsTests.cs new file mode 100644 index 00000000..64652e8f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalDenialRoundsTests.cs @@ -0,0 +1,159 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// + /// Approval hooks over many calls at once. MAF hands back one approval request per run and asks the rule again about every queued + /// request on every run, so the hooks must still decide each call once, and the deny layer's round cap must count + /// model rounds, not hand-backs. + /// + public sealed class ApprovalDenialRoundsTests + { + private const string MixedYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: wire_money, kind: builtin, uses: test.wire, description: "Wire money." } + - { id: archive, kind: builtin, uses: test.archive, description: "Archive the thread." } + - { id: send_email, kind: builtin, uses: test.send, description: "Send an email." } + agents: + items: + - id: only + instructions: "do it all" + tools: [ wire_money, archive, send_email ] + approval: { auto: [ archive ] } + entries: + main: + agent: only + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // h0 is left to a human and surfaces first, so MAF queues d2 and d3 across the turn the human answers in. The third + // turn reads the record the first two wrote. + [Fact] + public async Task EachCallOfAMixedRoundIsDecidedOnceAcrossTwoTurns() + { + ParallelToolChatClient model = new("done.", ("wire_money", "h0"), ("archive", "a1"), ("send_email", "d2"), ("send_email", "d3")); + ApprovalTools tools = new(); + DecidingHook hook = new(gate => + { + if (gate.CallId.StartsWith('d')) + { + gate.Deny("no"); + } + }); + RecordingHook notices = new(); + RecordingConversationStore store = new(); + ConversationSession session = tools.Session(MixedYaml, [hook, notices], model, store: store); + + TurnResult asked = await session.RunTurnAsync("do it all", Ct); + PendingApproval pending = Assert.Single(asked.Approvals); + TurnResult answered = await session.RunTurnMessageAsync(session.TryCreateApprovalAnswer(pending.RequestId, approved: true)!, Ct); + TurnResult next = await session.RunTurnAsync("thanks", Ct); + await session.FlushNoticesAsync(); + await session.FlushTranscriptAsync(); + + Assert.Equal(("done.", "done."), (answered.ReplyText, next.ReplyText)); + + // No person was asked about d2 and d3, so the record keeps only h0's request (and a1's, which MAF approved). + Assert.Equal( + ["a1", "h0"], + store.Rows.SelectMany(row => row.Content.Contents).OfType().Select(request => request.ToolCall.CallId).Order(StringComparer.Ordinal)); + Assert.Equal(["a1", "d2", "d3", "h0"], hook.Asked.Order(StringComparer.Ordinal)); + Assert.Equal((1, 1, 0), (tools.Ran("wire_money"), tools.Ran("archive"), tools.Sent)); + Assert.Equal( + [ + ("a1", ApprovalState.Approved, ApprovalBy.Rule), + ("d2", ApprovalState.Denied, ApprovalBy.Hook), + ("d3", ApprovalState.Denied, ApprovalBy.Hook), + ("h0", ApprovalState.Asked, ApprovalBy.Human), + ("h0", ApprovalState.Approved, ApprovalBy.Human), + ], + notices.Of().Select(changed => (changed.CallId, changed.State, changed.By))); + Assert.Equal( + [("a1", ToolOutcome.Ok), ("d2", ToolOutcome.Blocked), ("d3", ToolOutcome.Blocked), ("h0", ToolOutcome.Ok)], + notices.Of().Select(called => (called.CallId, called.Outcome)).Order()); + } + + // d0 surfaces first, so MAF queues h1 and asks the rule about it again when the deny layer answers d0. + [Fact] + public async Task ACallLeftToAHumanIsDecidedOnceWhileItWaitsInTheQueue() + { + ParallelToolChatClient model = new("done.", ("send_email", "d0"), ("wire_money", "h1")); + ApprovalTools tools = new(); + DecidingHook hook = new(gate => + { + if (gate.CallId.StartsWith('d')) + { + gate.Deny("no"); + } + }); + RecordingHook notices = new(); + ConversationSession session = tools.Session(MixedYaml, [hook, notices], model); + + TurnResult turn = await session.RunTurnAsync("do it all", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(["d0", "h1"], hook.Asked.Order(StringComparer.Ordinal)); + Assert.Equal("wire_money", Assert.Single(turn.Approvals).ToolName); + Assert.Equal( + [("d0", ApprovalState.Denied), ("h1", ApprovalState.Asked)], + notices.Of().Select(changed => (changed.CallId, changed.State))); + } + + // Ten denials are ten hand-backs and one model round, far below the cap: every call is refused and the model answers. + // The next turn reads the record this one wrote. + [Fact] + public async Task TenParallelDenialsAreEachDecidedOnceAndNeverReachAHuman() + { + ParallelToolChatClient model = new("done.", [.. Enumerable.Range(0, 10).Select(index => ("send_email", $"d{index}"))]); + ApprovalTools tools = new(); + DecidingHook hook = new(gate => gate.Deny("no")); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [hook, notices], model); + + TurnResult turn = await session.RunTurnAsync("send them", Ct); + TurnResult next = await session.RunTurnAsync("thanks", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(("done.", "done."), (turn.ReplyText, next.ReplyText)); + Assert.Empty(turn.Approvals); + Assert.Equal(0, tools.Sent); + Assert.Equal(10, hook.Asked.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(10, hook.Asked.Count); + Assert.All(notices.Of(), changed => Assert.Equal((ApprovalState.Denied, ApprovalBy.Hook), (changed.State, changed.By))); + Assert.Equal(10, notices.Of().Count); + Assert.Equal(10, notices.Of().Count(called => called.Outcome == ToolOutcome.Blocked)); + Assert.Equal(3, model.Calls); + } + + // At the cap a hook's denial still never reaches a person; the turn ends as a fault and speaks the fallback. + [Fact] + public async Task AModelThatKeepsCallingADeniedToolEndsInTheFallbackWithoutAskingAnyone() + { + TurnScriptChatClient model = TurnScriptChatClient.ToolForever("send_email"); + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [new DecidingHook(gate => gate.Deny("no")), notices], model); + + TurnResult turn = await session.RunTurnAsync("send it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(0, tools.Sent); + Assert.Empty(turn.Approvals); + Assert.NotNull(turn.Failure); + Assert.Equal(session.Compiled.FallbackReply, turn.ReplyText); + Assert.DoesNotContain(notices.Of(), changed => changed.By == ApprovalBy.Human); + Assert.Equal(FaultKind.DenialRoundsExhausted, Assert.Single(notices.Of()).Kind); + + // The first round plus the eight rounds of denials the layer answers. + Assert.Equal(9, model.Calls); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs new file mode 100644 index 00000000..030a2b59 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs @@ -0,0 +1,200 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Sessions; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ApprovalGateTests + { + // The document of ConversationSessionApprovalTests.cs:15-27 with an auto: pattern. + private const string AutoYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: send_email, kind: builtin, uses: test.send, description: "Send an email." } + agents: + items: + - id: only + instructions: "send the mail" + tools: [ send_email ] + approval: { auto: [ send_email ] } + entries: + main: + agent: only + """; + + private const string TwoToolYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: send_email, kind: builtin, uses: test.send, description: "Send an email." } + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + items: + - id: only + instructions: "send the mail" + tools: [ send_email, price_lookup ] + entries: + main: + agent: only + """; + + // What the model reads for a denied call, as for a human denial. + private const string Rejected = "Tool call invocation rejected. "; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AnApprovingHookRunsTheToolWithoutAsking() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [new DecidingHook(gate => gate.Approve()), notices]); + + TurnResult turn = await session.RunTurnAsync("send it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(1, tools.Sent); + Assert.Empty(turn.Approvals); + Assert.Equal("done.", turn.ReplyText); + ApprovalChanged changed = Assert.Single(notices.Of()); + Assert.Equal((ApprovalState.Approved, ApprovalBy.Hook, "send_email"), (changed.State, changed.By, changed.ToolName)); + } + + [Fact] + public async Task ADenyingHookRejectsTheCallAndTheModelReadsTheReason() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [new DecidingHook(gate => gate.Deny("policy says no")), notices]); + + TurnResult turn = await session.RunTurnAsync("send it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(0, tools.Sent); + Assert.Empty(turn.Approvals); + Assert.Contains(tools.Model.ToolResults, result => result.Contains(Rejected + "policy says no", StringComparison.Ordinal)); + ApprovalChanged changed = Assert.Single(notices.Of()); + Assert.Equal((ApprovalState.Denied, ApprovalBy.Hook, "policy says no"), (changed.State, changed.By, changed.Reason)); + } + + // With no decision the request still goes to a human. + [Fact] + public async Task NoDecisionStillAsksAHuman() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(ApprovalTools.GatedYaml, [new DecidingHook(_ => { }), notices]); + + TurnResult turn = await session.RunTurnAsync("send it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(0, tools.Sent); + _ = Assert.Single(turn.Approvals); + ApprovalChanged changed = Assert.Single(notices.Of()); + Assert.Equal((ApprovalState.Asked, ApprovalBy.Human), (changed.State, changed.By)); + } + + [Fact] + public async Task AnAutoPatternStillApprovesWhenNoHookDecides() + { + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(AutoYaml, [new DecidingHook(_ => { }), notices]); + + _ = await session.RunTurnAsync("send it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(1, tools.Sent); + Assert.Equal(ApprovalBy.Rule, Assert.Single(notices.Of()).By); + } + + // A hook's denial beats the document's auto: pattern. + [Fact] + public async Task AHookDenialBeatsAnAutoPattern() + { + ApprovalTools tools = new(); + ConversationSession session = tools.Session(AutoYaml, [new DecidingHook(gate => gate.Deny("not today"))]); + + TurnResult turn = await session.RunTurnAsync("send it", Ct); + + Assert.Equal(0, tools.Sent); + Assert.Empty(turn.Approvals); + Assert.Contains(tools.Model.ToolResults, result => result.Contains(Rejected + "not today", StringComparison.Ordinal)); + } + + // Closed applies the gate's safe verb and stops the chain, so the auto: pattern never approves. + [Fact] + public async Task AHookThatFailsClosedDeniesTheCall() + { + ApprovalTools tools = new(); + ConversationSession session = tools.Session(AutoYaml, [new FailsClosed()]); + + _ = await session.RunTurnAsync("send it", Ct); + + Assert.Equal(0, tools.Sent); + Assert.Contains(tools.Model.ToolResults, result => result.Contains(Rejected + "a hook denied this call.", StringComparison.Ordinal)); + } + + // The deny layer runs the agent again with the caller's options: the call after the denial is still one notice, + // and the denied call is a refused call, not an undeclared tool. TurnScriptChatClient gives call n the id "c{n}". + [Fact] + public async Task ADeniedCallIsBlockedAndTheCallAfterItIsOneNotice() + { + TurnScriptChatClient model = TurnScriptChatClient.ToolsThenText(["send_email", "price_lookup"], "done."); + ApprovalTools tools = new(); + RecordingHook notices = new(); + ConversationSession session = tools.Session(TwoToolYaml, [new DecidingHook(gate => gate.Deny("no")), notices], model); + + TurnResult turn = await session.RunTurnAsync("send it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal("done.", turn.ReplyText); + Assert.Equal((0, 1), (tools.Sent, tools.Ran("price_lookup"))); + Assert.Equal( + [("c0", ToolOutcome.Blocked), ("c1", ToolOutcome.Ok)], + notices.Of().Select(called => (called.CallId, called.Outcome)).Order()); + Assert.Equal(3, model.Calls); + Assert.Equal(3, notices.Of().Count); + } + + // A delegated agent tool runs its agent without streaming (DelegatedAgentRun), so the deny layer's other path. + [Fact] + public async Task ADenialHoldsOnARunThatDoesNotStream() + { + ApprovalTools tools = new(); + await using HookRuntime runtime = HookRuntime.Create([new DecidingHook(gate => gate.Deny("policy says no"))], loggers: null); + SessionHooks hooks = new(runtime, "conversation", "main", TimeProvider.System); + TurnInvocation turn = new() { ConversationId = "conversation", TurnIndex = 0, Stage = string.Empty, Hooks = hooks }; + ChatClientAgent inner = new(tools.Model, new ChatClientAgentOptions { ChatOptions = new ChatOptions { Tools = [tools.Send()] } }); + AIAgent agent = AgentApproval.Apply(inner, defaults: null, new AgentConfiguration { Id = "only", Instructions = "send" }, tools: null, runtime); + + AgentResponse response = await agent.RunAsync("send it", session: null, turn.RunOptions(), Ct); + await hooks.FlushAsync(); + hooks.Release(); + + Assert.Equal(0, tools.Sent); + Assert.Equal("done.", response.Text); + Assert.Contains(tools.Model.ToolResults, result => result.Contains(Rejected + "policy says no", StringComparison.Ordinal)); + Assert.Empty(response.Messages.SelectMany(message => message.Contents).OfType()); + } + + private sealed class FailsClosed : AgentHook + { + public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + + public override ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) => + throw new InvalidOperationException("policy store down"); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs new file mode 100644 index 00000000..c6a082cc --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Ports; +using AgentCore.Application.Tests.Fakes; +using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// The tools the approval-hook tests offer, each counting its runs: three that need approval and one that does not. + internal sealed class ApprovalTools + { + /// One agent with send_email, which needs approval. + internal const string GatedYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: send_email, kind: builtin, uses: test.send, description: "Send an email." } + agents: + items: + - id: only + instructions: "send the mail" + tools: [ send_email ] + entries: + main: + agent: only + """; + + private readonly ConcurrentDictionary _ran = new(StringComparer.Ordinal); + + /// Gets how many times send_email ran. + public int Sent => Ran("send_email"); + + /// Gets the model most tests use: it calls the first tool once, under the id conversation_1, then says "done.". + public ToolCallingChatClient Model { get; } = new("done.", new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" }); + + /// Gets how many times the named tool ran. + public int Ran(string tool) => _ran.TryGetValue(tool, out int count) ? count : 0; + + public ConversationSession Session( + string yaml, + IReadOnlyList hooks, + IChatClient? model = null, + IConversationStore? store = null) + { + return HookSessions.Create(yaml, model ?? Model, hooks, store: store, tools: Tool); + } + + public AIFunction? Tool(ToolConfiguration declared) + { + return declared.Uses switch + { + "test.send" => new ApprovalRequiredAIFunction(Counting("send_email")), + "test.wire" => new ApprovalRequiredAIFunction(Counting("wire_money")), + "test.archive" => new ApprovalRequiredAIFunction(Counting("archive")), + "orders.read" => Counting("price_lookup"), + _ => null, + }; + } + + public ApprovalRequiredAIFunction Send() => new(Counting("send_email")); + + private AIFunction Counting(string name) + { + return AIFunctionFactory.Create( + () => + { + _ = _ran.AddOrUpdate(name, 1, static (_, count) => count + 1); + return "ok"; + }, + name, + "A test tool."); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs new file mode 100644 index 00000000..82c6009f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs @@ -0,0 +1,152 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Diagnostics; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class AuditHookTests + { + private const string HelloSha256 = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + + private static readonly DateTimeOffset Moment = new(2026, 8, 15, 9, 30, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static HookScope Scope(int? turn = 0) => new("conversation-1", "main", turn, "", Guid.CreateVersion7(), 3, Moment); + + private static async Task RowOfAsync(Func deliver) + { + InMemoryAuditSink sink = new(); + await deliver(BuiltInHooks.Create(sink).OfType().Single()); + return Assert.Single(sink.EventsOf("conversation-1")); + } + + [Fact] + public async Task ATurnCompletedIsATurnCompletedRowWithTheReplyHash() + { + TurnCompleted completed = new(Scope(), TurnOutcome.Answered, "hi", "hello", "a", "b", TimeSpan.FromSeconds(1), null, false, null) { EventId = Guid.CreateVersion7() }; + + AuditEvent row = await RowOfAsync(hook => hook.OnTurnCompletedAsync(completed, Ct)); + + Assert.Equal((AuditEventKind.TurnCompleted, completed.EventId, Moment, (int?)0), (row.Kind, row.EventId, row.OccurredAt, row.TurnIndex)); + Assert.Equal(HelloSha256, row.Payload[AuditPayloadKeys.ReplyTextSha256]); + Assert.Equal(("a", "b"), (row.Payload[AuditPayloadKeys.StageBefore], row.Payload[AuditPayloadKeys.StageAfter])); + AuditEventVocabulary.Validate(row); + } + + [Fact] + public async Task AReplyCutAmendsItsTurnAndHashesWhatWasHeard() + { + Guid completed = Guid.CreateVersion7(); + ReplyCut cut = new(Scope(), "hello", TimeSpan.FromMilliseconds(1820), completed) { EventId = Guid.CreateVersion7() }; + + AuditEvent row = await RowOfAsync(hook => hook.OnReplyCutAsync(cut, Ct)); + + Assert.Equal((AuditEventKind.ReplyInterrupted, (Guid?)completed), (row.Kind, row.AmendsEventId)); + Assert.Equal(HelloSha256, row.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + Assert.Equal("1820", row.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); + AuditEventVocabulary.Validate(row); + } + + [Fact] + public async Task AFlaggedInputIsAPromptFlaggedRowWithItsCategoriesInOrder() + { + InputModerated flagged = new(Scope(), InputVerdict.Flagged, ["violence", "harassment"], null) { EventId = Guid.CreateVersion7() }; + + AuditEvent row = await RowOfAsync(hook => hook.OnInputModeratedAsync(flagged, Ct)); + + Assert.Equal(AuditEventKind.PromptFlagged, row.Kind); + Assert.Equal("violence,harassment", row.Payload[AuditPayloadKeys.ModerationCategories]); + } + + // Failed (fatal) and Undeclared are audited; TimedOut, Blocked and the rest are not. + [Theory] + [InlineData(ToolOutcome.Failed, true, true)] + [InlineData(ToolOutcome.Undeclared, false, true)] + [InlineData(ToolOutcome.Failed, false, false)] + [InlineData(ToolOutcome.TimedOut, false, false)] + [InlineData(ToolOutcome.Blocked, false, false)] + [InlineData(ToolOutcome.Ok, false, false)] + public async Task OnlyAFatalFailureOrAnUndeclaredToolIsAToolFailedRow(ToolOutcome outcome, bool fatal, bool audited) + { + InMemoryAuditSink sink = new(); + ToolFailureKind? kind = outcome == ToolOutcome.Undeclared ? ToolFailureKind.Undeclared : ToolFailureKind.Faulted; + ToolCalled called = new(Scope(), "lookup_order", "call-1", TimeSpan.Zero, outcome, fatal, kind, "boom") { EventId = Guid.CreateVersion7() }; + + await BuiltInHooks.Create(sink).OfType().Single().OnToolCalledAsync(called, Ct); + + Assert.Equal(audited ? 1 : 0, sink.EventsOf("conversation-1").Count); + if (audited) + { + AuditEvent row = sink.EventsOf("conversation-1")[0]; + Assert.Equal(("lookup_order", "call-1", "boom"), (row.Payload[AuditPayloadKeys.ToolName], row.Payload[AuditPayloadKeys.ToolCallId], row.Payload[AuditPayloadKeys.ToolError])); + Assert.Equal(ToolFailureKinds.ToToken(kind!.Value), row.Payload[AuditPayloadKeys.ToolFailureKind]); + } + } + + // A refusal after the end is no row. + [Fact] + public async Task ARefusalIsARowUnlessTheConversationHadEnded() + { + InMemoryAuditSink sink = new(); + AgentHook hook = BuiltInHooks.Create(sink).OfType().Single(); + + await hook.OnTurnRefusedAsync(new TurnRefused(Scope(turn: null), TurnRefusal.InUse, AfterEnd: false) { EventId = Guid.CreateVersion7() }, Ct); + await hook.OnTurnRefusedAsync(new TurnRefused(Scope(turn: null), TurnRefusal.Terminal, AfterEnd: true) { EventId = Guid.CreateVersion7() }, Ct); + + AuditEvent row = Assert.Single(sink.EventsOf("conversation-1")); + Assert.Equal((AuditEventKind.TurnRefused, "in_use"), (row.Kind, row.Payload[AuditPayloadKeys.RefusedReason])); + } + + [Fact] + public async Task TheEndCarriesItsReasonTokenAndTerminalStage() + { + ConversationEnded ended = new(Scope(turn: null), ConversationEndReason.AgentCompleted, "done", null) { EventId = Guid.CreateVersion7() }; + + AuditEvent row = await RowOfAsync(hook => hook.OnConversationEndedAsync(ended, Ct)); + + Assert.Equal(AuditEventKind.ConversationEnded, row.Kind); + Assert.Equal(ConversationEndReasons.ToToken(ConversationEndReason.AgentCompleted), row.Payload[AuditPayloadKeys.EndReason]); + Assert.Equal("done", row.Payload[AuditPayloadKeys.StageAfter]); + } + + [Fact] + public async Task ASupersedeAndAStartAreRowsToo() + { + InMemoryAuditSink sink = new(); + AgentHook hook = BuiltInHooks.Create(sink).OfType().Single(); + + await hook.OnConversationStartedAsync(new ConversationStarted(Scope(turn: null), ConversationOrigin.New) { EventId = Guid.CreateVersion7() }, Ct); + await hook.OnTurnSupersededAsync(new TurnSuperseded(Scope(turn: 3), 1, 2) { EventId = Guid.CreateVersion7() }, Ct); + + Assert.Equal([AuditEventKind.ConversationStarted, AuditEventKind.TurnSuperseded], sink.EventsOf("conversation-1").Select(row => row.Kind)); + Assert.Equal(("1", "2"), (sink.EventsOf("conversation-1")[1].Payload[AuditPayloadKeys.WithdrewFromTurnIndex], sink.EventsOf("conversation-1")[1].Payload[AuditPayloadKeys.WithdrewThroughTurnIndex])); + } + + // A refused row is a gap in the record: logged as AuditAppendFailed (EventId 5, Error), then thrown on to the engine. + [Fact] + public async Task ARowTheSinkRefusesIsLoggedAndThrownOn() + { + RecordingLogger logger = new(); + AgentHook hook = BuiltInHooks.Create(new ThrowingAuditSink(), logger).OfType().Single(); + TurnCompleted completed = new(Scope(), TurnOutcome.Answered, "hi", "hello", "a", "b", TimeSpan.FromSeconds(1), null, false, null) { EventId = Guid.CreateVersion7() }; + + _ = await Assert.ThrowsAsync(() => hook.OnTurnCompletedAsync(completed, Ct).AsTask()); + + LogLine line = Assert.Single(logger.Of(5)); + Assert.Equal(LogLevel.Error, line.Level); + } + + // The audit hook waits out a slow store rather than dropping. + [Fact] + public void TheAuditHookNeverTimesOut() + { + Assert.Null(BuiltInHooks.Create(new InMemoryAuditSink()).OfType().Single().NoticeTimeout); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/BeforeGates.cs b/tests/AgentCore.Application.Tests/Hooks/BeforeGates.cs new file mode 100644 index 00000000..ccc75002 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/BeforeGates.cs @@ -0,0 +1,34 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.Application.Tests.Hooks +{ + /// A hook that hands each of the four before-gates to an action, when one is given. + internal sealed class BeforeGates( + Action? turn = null, Action? run = null, Action? model = null, Action? tool = null) : AgentHook + { + public override ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) + { + turn?.Invoke(gate); + return default; + } + + public override ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) + { + run?.Invoke(gate); + return default; + } + + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) + { + model?.Invoke(gate); + return default; + } + + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + tool?.Invoke(gate); + return default; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/BuiltInHooksTests.cs b/tests/AgentCore.Application.Tests/Hooks/BuiltInHooksTests.cs new file mode 100644 index 00000000..5e5fb246 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/BuiltInHooksTests.cs @@ -0,0 +1,19 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class BuiltInHooksTests + { + // Built-ins run in this order: telemetry, then logging, then the audit chain. + [Fact] + public void TheBuiltInsRunInTheOrderTheObserversDid() + { + IReadOnlyList hooks = BuiltInHooks.Create(new InMemoryAuditSink()); + + Assert.Equal([typeof(TelemetryHook), typeof(LoggingHook), typeof(AuditHook)], hooks.Select(hook => hook.GetType())); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs new file mode 100644 index 00000000..9812dec5 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs @@ -0,0 +1,106 @@ +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// A call's brief reaches the engine as instructions, verbatim. + public sealed class CallBriefHookTests + { + // Leading and trailing spaces: a trim or a wrapper would change the brief, and the whole-part check sees it. + private const string Brief = " Earlier summary, for context only.\nAsk whether this call is about it. \"Do not\" read details out. "; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The agent's own instructions come first and the brief follows on a new line, so a brief that arrives + // untouched, with its edge spaces, is exactly what the instructions end with after that line break. + private static bool EndsWithTheBrief(string instructions) => instructions.EndsWith("\n" + Brief, StringComparison.Ordinal); + + // Instructions a run gate adds reach every round of the run. + [Fact] + public async Task ABriefReachesEveryRoundOfItsCallsRunVerbatim() + { + CallBriefHook briefs = new(); + RequestsSeen model = new(new ToolCallingChatClient("final")); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, model, [briefs], tools: new StubToolBuilder("{}").Create, conversationId: "call-a"); + briefs.Set("call-a", Brief); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal(2, model.Requests.Count); + Assert.All(model.Requests, request => Assert.True(EndsWithTheBrief(request.Instructions), request.Instructions)); + } + + [Fact] + public async Task ABriefReachesEveryTurnOfTheCallNotJustTheFirst() + { + CallBriefHook briefs = new(); + RequestsSeen model = new(new ToolCallingChatClient("final")); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, model, [briefs], tools: new StubToolBuilder("{}").Create, conversationId: "call-a"); + briefs.Set("call-a", Brief); + + _ = await session.RunTurnAsync("first", Ct); + int afterFirst = model.Requests.Count; + _ = await session.RunTurnAsync("second", Ct); + + Assert.True(model.Requests.Count > afterFirst); + Assert.All(model.Requests.Skip(afterFirst), request => Assert.True(EndsWithTheBrief(request.Instructions), request.Instructions)); + } + + // An agent-as-tool child is a nested run of the same conversation, and it gets the brief too. + [Fact] + public async Task AnAgentToolChildOfTheCallsRunGetsTheBriefToo() + { + CallBriefHook briefs = new(); + RequestsSeen model = new(new ToolCallingChatClient("done", new Dictionary(StringComparer.Ordinal) { ["query"] = "help me" })); + ConversationSession session = HookSessions.Create(HookSessions.DelegatingYaml, model, [briefs], conversationId: "call-a"); + briefs.Set("call-a", Brief); + + _ = await session.RunTurnAsync("go", Ct); + + Assert.Contains(model.Requests, request => request.Instructions.Contains("\nhelp\n", StringComparison.Ordinal)); + Assert.All(model.Requests, request => Assert.True(EndsWithTheBrief(request.Instructions), request.Instructions)); + } + + // A vendor that speaks for itself puts its own lines in the history; every run is told they were already heard. + [Fact] + public async Task AFrontVoiceCallGetsTheFrontVoiceNoteAfterItsBriefOnEveryRun() + { + CallBriefHook briefs = new(); + using ScriptedChatClient scripted = new("ok"); + RequestsSeen model = new(scripted); + ConversationSession call = HookSessions.Create(HookSessions.OneAgentYaml, model, [briefs], conversationId: "call-a"); + briefs.Set("call-a", Brief, frontVoice: true); + + _ = await call.RunTurnAsync("first", Ct); + briefs.Set("call-a", brief: null, frontVoice: true); + _ = await call.RunTurnAsync("second", Ct); + + Assert.EndsWith("\n" + Brief + "\n\n" + CallBriefHook.FrontVoiceNote, model.Requests[0].Instructions, StringComparison.Ordinal); + Assert.EndsWith("\n" + CallBriefHook.FrontVoiceNote, model.Requests[1].Instructions, StringComparison.Ordinal); + Assert.DoesNotContain(Brief, model.Requests[1].Instructions, StringComparison.Ordinal); + } + + [Fact] + public async Task AnotherConversationAndAClearedCallSeeNoBrief() + { + CallBriefHook briefs = new(); + using ScriptedChatClient scripted = new("ok"); + RequestsSeen model = new(scripted); + briefs.Set("call-a", Brief); + + ConversationSession web = HookSessions.Create(HookSessions.OneAgentYaml, model, [briefs], conversationId: "web-b"); + _ = await web.RunTurnAsync("hi", Ct); + Assert.DoesNotContain(Brief, model.Requests[^1].Instructions, StringComparison.Ordinal); + + briefs.Clear("call-a"); + ConversationSession call = HookSessions.Create(HookSessions.OneAgentYaml, model, [briefs], conversationId: "call-a"); + _ = await call.RunTurnAsync("hi", Ct); + Assert.DoesNotContain(Brief, model.Requests[^1].Instructions, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/CallEndNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/CallEndNoticeTests.cs new file mode 100644 index 00000000..e92efbcf --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/CallEndNoticeTests.cs @@ -0,0 +1,80 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// The end of a call's conversation names the call. + public sealed class CallEndNoticeTests + { + private static readonly DateTimeOffset Noon = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task TheEndOfACallNamesTheCallItsLengthAndTheCause() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], time: time); + Assert.True(session.Lifetime.Ending.TryMarkCall("call-7", static () => { }, time.GetUtcNow())); + time.Advance(TimeSpan.FromSeconds(42)); + + Assert.True(session.Lifetime.EndConversation(ConversationEndReason.CallerHungUp, "close_requested")); + await session.FlushNoticesAsync(); + + ConversationEnded ended = Assert.Single(hook.Of()); + Assert.Equal(ConversationEndReason.CallerHungUp, ended.Reason); + Assert.Equal(new CallEnd("call-7", 42, "close_requested"), ended.Call); + } + + // A call's length runs to the hang-up, not to the raise that waited on the running turn. + [Fact(Timeout = 30_000)] + public async Task AnEndAskedForDuringATurnMeasuresTheCallUpToTheAsk() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello", " there") { GateAfterFirstFragment = true }; + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], time: time); + Assert.True(session.Lifetime.Ending.TryMarkCall("call-9", static () => { }, time.GetUtcNow())); + await using IAsyncEnumerator stream = session.RunTurnStreamingAsync("hi", Ct).GetAsyncEnumerator(Ct); + Assert.True(await stream.MoveNextAsync()); + time.Advance(TimeSpan.FromSeconds(10)); + + _ = session.Lifetime.EndConversation(ConversationEndReason.CallerHungUp, "close_requested"); + await time.WaitForTimersAsync(time.GetUtcNow() + ConversationEnding.TurnWait, 1); + time.Advance(ConversationEnding.TurnWait); + ConversationEnded ended = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Assert.Equal(new CallEnd("call-9", 10, "close_requested"), ended.Call); + reply.OpenGate(); + while (await stream.MoveNextAsync()) + { + } + } + + // An end the agent makes itself still names the call, with no cause of the vendor's. + [Fact] + public async Task ATerminalStageEndOfACallNamesTheCallWithNoCause() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("goodbye"); + ConversationSession session = HookSessions.Create(HookSessions.EndsAfterTheFirstTurnYaml, reply, [hook], time: time); + Assert.True(session.Lifetime.Ending.TryMarkCall("call-8", static () => { }, time.GetUtcNow())); + time.Advance(TimeSpan.FromSeconds(5)); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + ConversationEnded ended = Assert.Single(hook.Of()); + Assert.Equal(ConversationEndReason.AgentCompleted, ended.Reason); + Assert.Equal(new CallEnd("call-8", 5, null), ended.Call); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/CallGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/CallGateTests.cs new file mode 100644 index 00000000..86ef8ac0 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/CallGateTests.cs @@ -0,0 +1,151 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class CallGateTests + { + private static readonly CallOffer Offer = new( + "main", "call-7", "+15550100", "+15550199", new Dictionary(StringComparer.Ordinal) { ["Diversion"] = "x" }, "telnyx-relay"); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // With no handler, the call id is the conversation id. + [Fact] + public async Task WithNoHookTheCallIsAcceptedUnderItsOwnId() + { + CallDecision decision = await CallGateChain.DecideAsync(HookRuntime.Create([], loggers: null), Offer, Ct); + + Assert.Equal(new CallDecision(true, "call-7", null, null), decision); + } + + [Fact] + public async Task AnAcceptingHookNamesTheConversationAndABriefCutToAThousandCharacters() + { + string longBrief = new('b', 1500); + Calls hook = new(gate => + { + Assert.Equal(("+15550100", "x"), (gate.From, gate.Headers["Diversion"])); + gate.Accept("thread-42", longBrief); + }); + + CallDecision decision = await CallGateChain.DecideAsync(HookRuntime.Create([hook], loggers: null), Offer, Ct); + + Assert.True(decision.Accepted); + Assert.Equal("thread-42", decision.ConversationId); + Assert.Equal(1000, decision.Brief!.Length); + } + + [Fact] + public async Task ARejectingHookRefusesWithItsReason() + { + CallDecision decision = await CallGateChain.DecideAsync( + HookRuntime.Create([new Calls(gate => gate.Reject(CallRefusal.Busy))], loggers: null), Offer, Ct); + + Assert.Equal(new CallDecision(false, null, null, CallRefusal.Busy), decision); + } + + // "no verb called" rejects as Unavailable. + [Fact] + public async Task HooksThatDecideNothingRejectTheCallAsUnavailable() + { + CallDecision decision = await CallGateChain.DecideAsync(HookRuntime.Create([new Calls(_ => { })], loggers: null), Offer, Ct); + + Assert.Equal(CallRefusal.Unavailable, decision.Refusal); + } + + // The safe verb applies, and the other hooks hear of the failure. + [Fact] + public async Task AThrowingHookFailsClosedAndTheOtherHooksHearOfIt() + { + RecordingHook notices = new(); + HookRuntime runtime = HookRuntime.Create([new Calls(_ => throw new InvalidOperationException("crm down")), notices], loggers: null); + + CallDecision decision = await CallGateChain.DecideAsync(runtime, Offer, Ct); + await runtime.Notices.FlushAsync(conversationId: null); + + Assert.Equal(CallRefusal.Unavailable, decision.Refusal); + Fault fault = Assert.Single(notices.Of()); + Assert.Equal(FaultKind.HookFailed, fault.Kind); + } + + // Closed ends the chain at the failure: a later hook cannot accept a call the gate could not vet. + [Fact] + public async Task AFailedHookEndsTheChainSoALaterHookCannotAcceptTheCall() + { + HookRuntime runtime = HookRuntime.Create( + [new Calls(_ => throw new InvalidOperationException("crm down")), new Calls(gate => gate.Accept("thread-42"))], loggers: null); + + CallDecision decision = await CallGateChain.DecideAsync(runtime, Offer, Ct); + + Assert.Equal(new CallDecision(false, null, null, CallRefusal.Unavailable), decision); + } + + [Fact(Timeout = 10_000)] + public async Task AHookThatMissesTheFiveSecondDeadlineFailsClosed() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + Calls stuck = new(_ => { }) { Delay = (Timeout.InfiniteTimeSpan, TimeProvider.System) }; + HookRuntime runtime = HookRuntime.Create([stuck], loggers: null, timers: time); + + Task deciding = CallGateChain.DecideAsync(runtime, Offer, Ct).AsTask(); + await time.WaitForTimersAsync(time.GetUtcNow() + GatePoint.BeforeCall.Deadline, 2); + time.Advance(GatePoint.BeforeCall.Deadline); + + Assert.Equal(CallRefusal.Unavailable, (await deciding).Refusal); + } + + // Providers.conversation.answerSeconds is the deadline of the call gate. + [Fact(Timeout = 10_000)] + public async Task AShorterAnswerDeadlineRejectsAStuckHookSooner() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + Calls stuck = new(_ => { }) { Delay = (Timeout.InfiniteTimeSpan, TimeProvider.System) }; + HookRuntime runtime = HookRuntime.Create([stuck], loggers: null, timers: time); + TimeSpan answer = TimeSpan.FromSeconds(2); + + Task deciding = CallGateChain.DecideAsync(runtime, Offer, Ct, answer).AsTask(); + await time.WaitForTimersAsync(time.GetUtcNow() + answer, 2); + time.Advance(answer); + + Assert.Equal(CallRefusal.Unavailable, (await deciding).Refusal); + } + + [Fact(Timeout = 10_000)] + public async Task ALongerAnswerDeadlineWaitsForAHookTheDefaultWouldHaveAbandoned() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + TimeSpan hookTakes = TimeSpan.FromSeconds(5.5); + Calls slow = new(gate => gate.Accept("thread-9")) { Delay = (hookTakes, time) }; + HookRuntime runtime = HookRuntime.Create([slow], loggers: null, timers: time); + TimeSpan answer = TimeSpan.FromSeconds(6); + + Task deciding = CallGateChain.DecideAsync(runtime, Offer, Ct, answer).AsTask(); + await time.WaitForTimersAsync(time.GetUtcNow() + answer, 2); + await time.WaitForTimersAsync(time.GetUtcNow() + hookTakes, 1); + time.Advance(hookTakes); + + Assert.Equal(new CallDecision(true, "thread-9", null, null), await deciding); + } + + private sealed class Calls(Action decide) : AgentHook + { + public (TimeSpan Wait, TimeProvider Clock)? Delay { get; init; } + + public override async ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) + { + if (Delay is { } delay) + { + await Task.Delay(delay.Wait, delay.Clock, CancellationToken.None); + } + + decide(gate); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs new file mode 100644 index 00000000..a94ac6c7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs @@ -0,0 +1,144 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; +using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class CompactionGateTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The hook's text is the summary row; the summariser is not asked. + [Fact] + public async Task AReplacedSummaryIsTheRowThatIsStored() + { + InMemoryConversationStore store = await SeededStoreAsync(); + ScriptedChatClient summariser = new("the summariser's gist"); + ConversationSession session = CreateSession( + new ScriptedChatClient("a3"), store, conversationId: "c1", + compaction: Summary(summariser, minimumPreservedGroups: 0), + hooks: [new Gate(gate => gate.ReplaceSummary("the hook's summary"))]); + + _ = await session.RunTurnAsync("q3", Ct); + await session.FlushTranscriptAsync(); + + ConversationMessage summary = Assert.Single( + await store.ReadForSessionAsync("c1", Ct), row => row.CoversUpTo is not null); + Assert.Contains("the hook's summary", summary.Content.Text, StringComparison.Ordinal); + Assert.DoesNotContain("the summariser's gist", summary.Content.Text, StringComparison.Ordinal); + Assert.Equal(0, summariser.Calls); + } + + // A gate that fails Closed applies its safe verb, and the compaction gate's safe verb is Cancel. + [Fact] + public async Task AClosedGateThatFailsCancelsTheCompaction() + { + InMemoryConversationStore store = await SeededStoreAsync(); + RecordingHook notices = new(); + ScriptedChatClient summariser = new("the gist"); + ConversationSession session = CreateSession( + new ScriptedChatClient("a3"), store, conversationId: "c1", + compaction: Summary(summariser, minimumPreservedGroups: 0), + hooks: [new Throwing(HookFailure.Closed), notices]); + + _ = await session.RunTurnAsync("q3", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + Assert.DoesNotContain(await store.ReadForSessionAsync("c1", Ct), row => row.CoversUpTo is not null); + Assert.Equal(0, summariser.Calls); + Assert.Equal(CompactionOutcome.Cancelled, Assert.Single(notices.Of()).Outcome); + } + + // A gate that fails Open leaves the compaction as if the hook were not there. + [Fact] + public async Task AnOpenGateThatFailsLetsTheCompactionRun() + { + InMemoryConversationStore store = await SeededStoreAsync(); + RecordingHook notices = new(); + ConversationSession session = CreateSession( + new ScriptedChatClient("a3"), store, conversationId: "c1", + compaction: Summary(new ScriptedChatClient("the summariser's gist"), minimumPreservedGroups: 0), + hooks: [new Throwing(HookFailure.Open), notices]); + + _ = await session.RunTurnAsync("q3", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + ConversationMessage summary = Assert.Single( + await store.ReadForSessionAsync("c1", Ct), row => row.CoversUpTo is not null); + Assert.Contains("the summariser's gist", summary.Content.Text, StringComparison.Ordinal); + Assert.Equal(CompactionOutcome.Compacted, Assert.Single(notices.Of()).Outcome); + } + + // Nothing is compacted, and the turn stands. + [Fact] + public async Task ACancelledCompactionKeepsTheView() + { + InMemoryConversationStore store = await SeededStoreAsync(); + RecordingHook notices = new(); + ConversationSession session = CreateSession( + new ScriptedChatClient("a3"), store, conversationId: "c1", + compaction: Summary(new ScriptedChatClient("the gist"), minimumPreservedGroups: 0), + hooks: [new Gate(gate => gate.Cancel()), notices]); + + TurnResult turn = await session.RunTurnAsync("q3", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + Assert.Equal("a3", turn.ReplyText); + Assert.DoesNotContain(await store.ReadForSessionAsync("c1", Ct), row => row.CoversUpTo is not null); + Assert.Equal(CompactionOutcome.Cancelled, Assert.Single(notices.Of()).Outcome); + } + + // A compaction is named, with how many messages it left. The gate counts the messages the strategy may fold: the stored turns before the newest one. + [Fact] + public async Task ACompactionIsNamedWithItsCounts() + { + InMemoryConversationStore store = await SeededStoreAsync(); + RecordingHook notices = new(); + List counts = []; + ConversationSession session = CreateSession( + new ScriptedChatClient("a3"), store, conversationId: "c1", + compaction: Summary(new ScriptedChatClient("the gist"), minimumPreservedGroups: 0), + hooks: [new Gate(gate => counts.Add(gate.MessageCount)), notices]); + + _ = await session.RunTurnAsync("q3", Ct); + await session.FlushNoticesAsync(); + + Compacted compacted = Assert.Single(notices.Of()); + Assert.Equal(CompactionOutcome.Compacted, compacted.Outcome); + // Before: six stored rows and the new question. After: one summary, the newest stored turn's two rows, the new question. + Assert.Equal(7, compacted.MessagesBefore); + Assert.Equal(4, compacted.MessagesAfter); + Assert.Equal(4, Assert.Single(counts)); + } + + private sealed class Throwing(HookFailure policy) : AgentHook + { + public override HookFailure FailureFor(GatePoint point) => policy; + + public override ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) + { + throw new InvalidOperationException("the hook broke"); + } + } + + private sealed class Gate(Action decide) : AgentHook + { + public override ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) + { + decide(gate); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ConversationEndNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ConversationEndNoticeTests.cs new file mode 100644 index 00000000..61450083 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ConversationEndNoticeTests.cs @@ -0,0 +1,266 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ConversationEndNoticeTests + { + private const string StagedToolYaml = """ + apiVersion: agentcore/v1 + guards: + never: { ">=": [ { var: turnIndex }, 99 ] } + tools: + - { id: transfer, kind: builtin, uses: orders.read, description: "Put the caller through to a person." } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok", tools: [ transfer ] } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: never } ] } + - { id: done, agent: only, terminal: true } + """; + + private const string EndsAtOnceYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + policy: + initial: done + stages: + - { id: done, agent: only, terminal: true } + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AnEndWithNoTurnRunningIsRaisedAtOnceAndLast() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + _ = await session.RunTurnAsync("hi", Ct); + + Assert.True(session.EndConversation(ConversationEndReason.CallerHungUp)); + _ = await Assert.ThrowsAsync(() => session.RunTurnAsync("again", Ct)); + await session.FlushNoticesAsync(); + + ConversationEnded ended = Assert.Single(hook.Of()); + Assert.Equal(ConversationEndReason.CallerHungUp, ended.Reason); + Assert.Null(ended.TerminalStage); + Assert.True(hook.Of().Single().Scope.Sequence < ended.Scope.Sequence); + TurnRefused refused = Assert.Single(hook.Of()); + Assert.Equal(TurnRefusal.Terminal, refused.Reason); + Assert.True(refused.AfterEnd); + } + + // A reloaded conversation that an earlier session ended has its end in the chain already, so a notice + // that must follow the end is raised at once instead of waiting for an end that never comes. + [Fact] + public async Task ANoticeThatFollowsTheEndIsRaisedAtOnceOnAReloadedEndedConversation() + { + InMemoryConversationStore store = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession first = HookSessions.Create(EndsAtOnceYaml, reply, store: store, conversationId: "conversation-1"); + _ = await first.RunTurnAsync("hi", Ct); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + RecordingHook hook = new(); + ConversationSession second = HookSessions.Create(EndsAtOnceYaml, reply, [hook], store, conversationId: "conversation-1"); + _ = await Assert.ThrowsAsync(() => second.RunTurnAsync("are you there?", Ct)); + second.Lifetime.Ending.RaiseAfterEnd(new CallEnded(second.Hooks.Scope(turnIndex: null, stage: null), "call-1", 1, Cause: null, CallEndReason.Ended)); + await second.FlushNoticesAsync(); + + Assert.Equal("call-1", Assert.Single(hook.Of()).CallId); + } + + // A session that ends before it opened its store still starts first, and says it never looked. + [Fact] + public async Task AnEndBeforeAnyTurnRaisesAnUnopenedStartFirst() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + Assert.True(session.EndConversation(ConversationEndReason.CallerHungUp)); + await session.FlushNoticesAsync(); + + Assert.Equal([typeof(ConversationStarted), typeof(ConversationEnded)], hook.Notices.Select(notice => notice.GetType())); + Assert.Equal(ConversationOrigin.Unopened, hook.Of().Single().Origin); + Assert.True(hook.Notices[0].Scope.Sequence < hook.Notices[1].Scope.Sequence); + } + + // A tool ends the conversation inside its own turn; the turn finishes first. + [Fact] + public async Task AnEndCalledInsideATurnIsRaisedAfterThatTurnCompletes() + { + RecordingHook hook = new(); + ConversationSession? session = null; + ToolCallingChatClient model = new("goodbye"); + session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [hook], + tools: tool => AIFunctionFactory.Create( + () => + { + _ = session!.EndConversation(ConversationEndReason.TransferredToHuman); + return "transferred"; + }, + tool.Id, + tool.Description ?? tool.Id)); + + TurnResult turn = await session.RunTurnAsync("put me through", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal("goodbye", turn.ReplyText); + Assert.Equal( + [typeof(TurnCompleted), typeof(ConversationEnded)], + hook.Notices.Where(n => n is TurnCompleted or ConversationEnded).Select(n => n.GetType())); + Assert.Equal(ConversationEndReason.TransferredToHuman, hook.Of().Single().Reason); + } + + // An end that waits on a turn waits 5 s, then is raised; the late seal still gets through. + [Fact(Timeout = 30_000)] + public async Task AnEndWaitsFiveSecondsForTheRunningTurnAndItsLateSealStillGetsThrough() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello", " there") { GateAfterFirstFragment = true }; + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], time: time); + + await using IAsyncEnumerator stream = session.RunTurnStreamingAsync("hi", Ct).GetAsyncEnumerator(Ct); + Assert.True(await stream.MoveNextAsync()); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await session.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + + await time.WaitForTimersAsync(time.GetUtcNow() + ConversationEnding.TurnWait, 1); + time.Advance(ConversationEnding.TurnWait); + _ = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + + reply.OpenGate(); + while (await stream.MoveNextAsync()) + { + } + + await session.FlushNoticesAsync(); + Assert.Equal( + [typeof(ConversationEnded), typeof(TurnCompleted)], + hook.Notices.Where(n => n is TurnCompleted or ConversationEnded).Select(n => n.GetType())); + } + + // A turn dropped unread never seals, so the end goes out when it frees the conversation, not 5 s later. + [Fact(Timeout = 30_000)] + public async Task AnEndWaitingOnATurnThatNeverSealsIsRaisedWhenTheTurnLetsGo() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], time: time); + TurnRun unread = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "hi"), origin: null, Ct); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await unread.DisposeAsync(); + + ConversationEnded ended = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + Assert.Equal(ConversationEndReason.CallerHungUp, ended.Reason); + Assert.Equal(DateTimeOffset.UnixEpoch, ended.Scope.OccurredAt); + } + + // Once a tool ended a staged conversation, the seal of its turn, which left the machine in a + // stage that is not terminal, does not open the conversation again. The next turn is refused after the end. + [Fact] + public async Task ATurnAfterAToolEndedAStagedConversationIsRefusedAfterTheEnd() + { + RecordingHook hook = new(); + ConversationSession? session = null; + ToolCallingChatClient model = new("goodbye"); + session = HookSessions.Create( + StagedToolYaml, + model, + [hook], + tools: tool => AIFunctionFactory.Create( + () => + { + _ = session!.EndConversation(ConversationEndReason.TransferredToHuman); + return "transferred"; + }, + tool.Id, + tool.Description ?? tool.Id)); + + TurnResult turn = await session.RunTurnAsync("put me through", Ct); + + Assert.True(turn.IsTerminal); + Assert.True(session.IsComplete); + _ = await Assert.ThrowsAsync(() => session.RunTurnAsync("are you there?", Ct)); + await session.FlushNoticesAsync(); + + _ = Assert.Single(hook.Of()); + TurnRefused refused = Assert.Single(hook.Of()); + Assert.Equal(TurnRefusal.Terminal, refused.Reason); + Assert.True(refused.AfterEnd); + } + + // A turn that waited for the conversation while the host ended it is not admitted once the + // running turn of a staged conversation seals. + [Fact(Timeout = 30_000)] + public async Task ATurnWaitingWhileTheHostEndsAStagedConversationIsRefusedAfterTheEnd() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello", " there") { GateAfterFirstFragment = true }; + ConversationSession session = HookSessions.Create(StagedToolYaml, reply, [hook], time: time); + + await using IAsyncEnumerator stream = session.RunTurnStreamingAsync("hi", Ct).GetAsyncEnumerator(Ct); + Assert.True(await stream.MoveNextAsync()); + Task waiting = session.RunTurnAsync("hello?", Ct); + await time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.WaitLimit, 1); + + Assert.True(session.EndConversation(ConversationEndReason.CallerHungUp)); + reply.OpenGate(); + while (await stream.MoveNextAsync()) + { + } + + _ = await Assert.ThrowsAsync(() => waiting); + await session.FlushNoticesAsync(); + + _ = Assert.Single(hook.Of()); + Assert.True(Assert.Single(hook.Of()).AfterEnd); + } + + // Terminal stage: the end comes from the seal, with the stage the machine stopped in. + [Fact] + public async Task ATerminalStageEndsTheConversationWithItsStage() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("done"); + ConversationSession session = HookSessions.Create(HookSessions.EndsAfterTheFirstTurnYaml, reply, [hook]); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + ConversationEnded ended = Assert.Single(hook.Of()); + Assert.Equal(ConversationEndReason.AgentCompleted, ended.Reason); + Assert.Equal("done", ended.TerminalStage); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ConversationEndReloadTests.cs b/tests/AgentCore.Application.Tests/Hooks/ConversationEndReloadTests.cs new file mode 100644 index 00000000..923f2fab --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ConversationEndReloadTests.cs @@ -0,0 +1,170 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + // A host end after the last turn is the normal phone hang-up: it must survive a reload. + public sealed class ConversationEndReloadTests + { + private const string StagedYaml = """ + apiVersion: agentcore/v1 + guards: + never: { ">=": [ { var: turnIndex }, 99 ] } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: never } ] } + - { id: done, agent: only, terminal: true } + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(HookSessions.OneAgentYaml)] + [InlineData(StagedYaml)] + public async Task AHostEndAfterAFinishedTurnRefusesTheNextTurnAfterAReload(string yaml) + { + InMemoryConversationStore store = new(); + using ScriptedChatClient reply = new("hello", "hello again"); + ConversationSession first = HookSessions.Create(yaml, reply, store: store, conversationId: "conversation-1"); + _ = await first.RunTurnAsync("hi", Ct); + Assert.True(first.EndConversation(ConversationEndReason.CallerHungUp)); + _ = await Assert.ThrowsAsync(() => first.RunTurnAsync("again", Ct)); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + RecordingHook hook = new(); + ConversationSession second = HookSessions.Create(yaml, reply, [hook], store, conversationId: "conversation-1"); + _ = await Assert.ThrowsAsync(() => second.RunTurnAsync("are you there?", Ct)); + await second.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + } + + // The turn read its state before the end, so its own append stores the conversation as still open. + [Theory] + [InlineData(HookSessions.OneAgentYaml)] + [InlineData(StagedYaml)] + public async Task AHostEndWhileTheTurnIsSavingRefusesTheNextTurnAfterAReload(string yaml) + { + HeldAppendStore store = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession first = HookSessions.Create(yaml, reply, store: store, conversationId: "conversation-1"); + Task turn = first.RunTurnAsync("hi", Ct); + await store.Holding.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Assert.True(first.EndConversation(ConversationEndReason.CallerHungUp)); + store.Release.SetResult(); + _ = await turn; + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + RecordingHook hook = new(); + ConversationSession second = HookSessions.Create(yaml, reply, [hook], store, conversationId: "conversation-1"); + _ = await Assert.ThrowsAsync(() => second.RunTurnAsync("are you there?", Ct)); + await second.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + } + + // A reloaded conversation the host ends before this session ran a turn: no session is open to write through. + [Theory] + [InlineData(HookSessions.OneAgentYaml)] + [InlineData(StagedYaml)] + public async Task AHostEndBeforeAnyTurnOfAReloadedSessionRefusesTheNextTurnAfterAnotherReload(string yaml) + { + InMemoryConversationStore store = new(); + using ScriptedChatClient reply = new("hello", "hello again"); + ConversationSession first = HookSessions.Create(yaml, reply, store: store, conversationId: "conversation-1"); + _ = await first.RunTurnAsync("hi", Ct); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + ConversationSession second = HookSessions.Create(yaml, reply, store: store, conversationId: "conversation-1"); + Assert.True(second.EndConversation(ConversationEndReason.CallerHungUp)); + await second.FlushTranscriptAsync(); + await second.DisposeAsync(); + + RecordingHook hook = new(); + ConversationSession third = HookSessions.Create(yaml, reply, [hook], store, conversationId: "conversation-1"); + _ = await Assert.ThrowsAsync(() => third.RunTurnAsync("are you there?", Ct)); + await third.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + Assert.Equal(1, (await store.GetAsync("conversation-1", Ct))!.State!.NextTurnIndex); + } + + // The end is still on its way to the store when the same session opens the conversation for a turn. + [Fact] + public async Task AHostEndBeforeAnyTurnRefusesATurnOfTheSameSession() + { + HeldStateStore store = new(); + using ScriptedChatClient reply = new("hello", "hello again"); + ConversationSession first = HookSessions.Create(HookSessions.OneAgentYaml, reply, store: store, conversationId: "conversation-1"); + _ = await first.RunTurnAsync("hi", Ct); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + ConversationSession second = HookSessions.Create(HookSessions.OneAgentYaml, reply, store: store, conversationId: "conversation-1"); + store.Hold = true; + Assert.True(second.EndConversation(ConversationEndReason.CallerHungUp)); + Exception? refused = await Record.ExceptionAsync(() => second.RunTurnAsync("are you there?", Ct)); + store.Release.SetResult(); + await second.FlushTranscriptAsync(); + + _ = Assert.IsType(refused); + } + + /// A store that holds every state-only write, once is set, until completes. + private sealed class HeldStateStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + public bool Hold { get; set; } + + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async ValueTask SaveStateAsync( + string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default) + { + if (Hold) + { + await Release.Task.WaitAsync(cancellationToken); + } + + await base.SaveStateAsync(conversationId, state, cancellationToken); + } + } + + /// A store that holds the first turn's append until completes. + private sealed class HeldAppendStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + public TaskCompletionSource Holding { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + if (messages.Count > 0 && Holding.TrySetResult()) + { + await Release.Task.WaitAsync(cancellationToken); + } + + return await base.AppendAsync(conversationId, messages, state, cancellationToken); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/CutAndFaultNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/CutAndFaultNoticeTests.cs new file mode 100644 index 00000000..db403e42 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/CutAndFaultNoticeTests.cs @@ -0,0 +1,77 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class CutAndFaultNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The cut amends the TurnCompleted it follows. + [Fact] + public async Task ACutDuringTheReplyAmendsTheTurnItCut() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello", " there", " caller") { GateAfterFirstFragment = true }; + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + await using IAsyncEnumerator stream = session.RunTurnStreamingAsync("hi", Ct).GetAsyncEnumerator(Ct); + Assert.True(await stream.MoveNextAsync()); + Assert.True(session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(300)))); + reply.OpenGate(); + while (await stream.MoveNextAsync()) + { + } + + await session.FlushNoticesAsync(); + + TurnCompleted completed = Assert.Single(hook.Of()); + ReplyCut cut = Assert.Single(hook.Of()); + Assert.Equal("Hello", cut.HeardText); + Assert.Equal(TimeSpan.FromMilliseconds(300), cut.Played); + Assert.Equal(completed.EventId, cut.AmendsEventId); + Assert.True(completed.Scope.Sequence < cut.Scope.Sequence); + } + + [Fact] + public async Task ACutAfterTheSealAmendsTheTurnLate() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello there"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + _ = await session.RunTurnAsync("hi", Ct); + + Assert.True(session.Cut(0, new TurnCut("Hel", TimeSpan.FromMilliseconds(100)))); + await session.FlushNoticesAsync(); + + ReplyCut cut = Assert.Single(hook.Of()); + Assert.Equal(("Hel", (TimeSpan?)TimeSpan.FromMilliseconds(100)), (cut.HeardText, cut.Played)); + Assert.Equal(hook.Of().Single().EventId, cut.AmendsEventId); + } + + // Turn indexes copied from ConversationSessionStoreFailureTests (write at 0, resync at 1). + [Fact] + public async Task AStoreThatRefusesWritesAndReadsRaisesTwoFaults() + { + RecordingHook hook = new(); + using RequestRecordingChatClient reply = new("hi there", "it ships Friday"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], new ThrowingConversationStore()); + + _ = await session.RunTurnAsync("hello", Ct); + _ = await session.RunTurnAsync("order 41?", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + Fault written = hook.Of().First(f => f.Kind == FaultKind.TranscriptWriteFailed); + Fault resync = Assert.Single(hook.Of(), f => f.Kind == FaultKind.TranscriptResyncFailed); + Assert.Equal(0, written.Scope.TurnIndex); + Assert.Equal(1, resync.Scope.TurnIndex); + Assert.NotNull(resync.Cause); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/DecidingHook.cs b/tests/AgentCore.Application.Tests/Hooks/DecidingHook.cs new file mode 100644 index 00000000..2bbbddd1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/DecidingHook.cs @@ -0,0 +1,34 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.Application.Tests.Hooks +{ + /// An approval hook that decides each call with the given action and keeps every call id it was asked about. + internal sealed class DecidingHook(Action decide) : AgentHook + { + private readonly List _asked = []; + + /// Gets the call id of every time the hook ran, in order. + public IReadOnlyList Asked + { + get + { + lock (_asked) + { + return [.. _asked]; + } + } + } + + public override ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) + { + lock (_asked) + { + _asked.Add(gate.CallId); + } + + decide(gate); + return default; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs b/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs new file mode 100644 index 00000000..f4889708 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs @@ -0,0 +1,57 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Hooks +{ + /// Streams "final", " text". Each attempt may throw first, after the first update, or not at all. + internal sealed class FlakyModel(Func script) : IChatClient + { + internal enum Fault + { + None, + BeforeFirstUpdate, + AfterFirstUpdate, + } + + private int _attempts; + + public int Attempts => Volatile.Read(ref _attempts); + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Fault fault = script(Interlocked.Increment(ref _attempts) - 1); + await Task.Yield(); + if (fault == Fault.BeforeFirstUpdate) + { + throw new InvalidOperationException("model down"); + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, "final") { MessageId = "m" }; + if (fault == Fault.AfterFirstUpdate) + { + throw new InvalidOperationException("model dropped"); + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, " text") { MessageId = "m" }; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/GateOverheadSuite.cs b/tests/AgentCore.Application.Tests/Hooks/GateOverheadSuite.cs new file mode 100644 index 00000000..88ea3e36 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/GateOverheadSuite.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + /// The collection runs in, alone, so no other test shares its clock time. + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class GateOverheadSuite + { + /// The name both the definition and the test class name it by. + public const string Name = "gate overhead"; + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs b/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs new file mode 100644 index 00000000..eeb80653 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + [Collection(GateOverheadSuite.Name)] + [Trait("Kind", "Timing")] + public sealed class GateOverheadTests + { + private const double TargetMicroseconds = 50; + + private const double AddedPerTurnMicroseconds = 3_000; + + private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 0, DateTimeOffset.UnixEpoch); + + // The target is under 50 µs per gate with no overriding hook. A miss of the target on a + // loaded machine is reported in the output, not failed. The bounds that fail the run are 250 µs with no + // hook and 100 µs per no-op hook, over 40 times what this host measured. + [Theory(Timeout = 60_000)] + [InlineData(0, 250)] + [InlineData(1, 100)] + [InlineData(5, 500)] + public async Task OneGateRunCostsMicroseconds(int hooks, double boundMicroseconds) + { + const int Runs = 5_000; + GateRunner runner = new(HookTable.Build([.. NoOps(hooks)]), NullLogger.Instance, TimeProvider.System); + await RunAsync(runner); + + long started = Stopwatch.GetTimestamp(); + for (int run = 0; run < Runs; run++) + { + await RunAsync(runner); + } + + double mean = Stopwatch.GetElapsedTime(started).TotalMicroseconds / Runs; + string target = mean < TargetMicroseconds ? "met" : "missed"; + TestContext.Current.TestOutputHelper?.WriteLine($"H3 gate run, {hooks} hooks: {mean:0.000} µs (50 µs target {target})"); + + Assert.True(mean < boundMicroseconds, $"{hooks} hooks: {mean:0.000} µs per gate run"); + } + + // Per tool call, through the compiled pipeline: one and five no-op tool and model hooks against none. + [Fact(Timeout = 60_000)] + public async Task FiveHooksAddLittleToATurnWithOneToolCall() + { + double[] medians = await MedianTurnsAsync([], [.. NoOps(1)], [.. NoOps(5)]); + TestContext.Current.TestOutputHelper?.WriteLine( + $"H3 turn with one tool call: {medians[0]:0.0} µs with no hooks, {medians[1]:0.0} µs with one, {medians[2]:0.0} µs with five"); + + // One tool call and two model rounds pass 2 tool gates and 4 model gates per hook: + // 30 gate runs for five hooks, which this host runs in tens of µs. + Assert.True(medians[1] - medians[0] < AddedPerTurnMicroseconds, $"one hook added {medians[1] - medians[0]:0.0} µs to one turn"); + Assert.True(medians[2] - medians[0] < AddedPerTurnMicroseconds, $"five hooks added {medians[2] - medians[0]:0.0} µs to one turn"); + } + + private static ValueTask RunAsync(GateRunner runner) => + runner.RunAsync( + GatePoint.BeforeTool, + Scope, + 0, + _ => new ToolGate(Scope, "t", "c", new Dictionary(), new Dictionary()), + static (hook, gate, token) => hook.BeforeToolAsync(gate, token), + static (state, _) => state + 1, + static state => state, + raiseFault: null, + CancellationToken.None); + + // The median of 50 timed turns per hook list, which is steadier than a mean. The lists take turns, so a + // slow spell of the machine lands on all of them alike, and the first round is an untimed warm-up. + private static async Task MedianTurnsAsync(params IReadOnlyList[] hookLists) + { + const int Turns = 50; + List[] times = [.. hookLists.Select(_ => new List())]; + for (int turn = 0; turn <= Turns; turn++) + { + for (int list = 0; list < hookLists.Length; list++) + { + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, new ToolCallingChatClient("final"), hookLists[list], tools: new StubToolBuilder("{}").Create); + long started = Stopwatch.GetTimestamp(); + _ = await session.RunTurnAsync("how much?", TestContext.Current.CancellationToken); + if (turn > 0) + { + times[list].Add(Stopwatch.GetElapsedTime(started).TotalMicroseconds); + } + } + } + + return [.. times.Select(static list => list.Order().ElementAt(list.Count / 2))]; + } + + private static IEnumerable NoOps(int count) => Enumerable.Range(0, count).Select(_ => new NoOp()); + + private sealed class NoOp : AgentHook + { + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + + public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => default; + + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => default; + + public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => default; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs b/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs new file mode 100644 index 00000000..91bc0898 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs @@ -0,0 +1,51 @@ +using AgentCore.Application.Hooks; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class GatePointTests + { + // Deadline and default failure F of each of the 13 gates. + public static TheoryData Points => new() + { + { GatePoint.BeforeCall, 5, HookFailure.Closed }, + { GatePoint.BeforeEntry, 2, HookFailure.Closed }, + { GatePoint.BeforeTurn, 2, HookFailure.Open }, + { GatePoint.BeforeRun, 2, HookFailure.Open }, + { GatePoint.BeforeCompaction, 5, HookFailure.Open }, + { GatePoint.BeforeModel, 1, HookFailure.Open }, + { GatePoint.AfterModel, 1, HookFailure.Open }, + { GatePoint.AfterModelFailed, 1, HookFailure.Open }, + { GatePoint.BeforeToolApproval, 2, HookFailure.Open }, + { GatePoint.BeforeTool, 2, HookFailure.Open }, + { GatePoint.AfterTool, 2, HookFailure.Open }, + { GatePoint.AfterToolFailed, 2, HookFailure.Open }, + { GatePoint.AfterRun, 2, HookFailure.Open }, + }; + + [Theory] + [MemberData(nameof(Points))] + public void EachPointHasTheSpecsDeadlineAndDefault(GatePoint point, double seconds, HookFailure failure) + { + Assert.Equal(TimeSpan.FromSeconds(seconds), point.Deadline); + Assert.Equal(failure, point.DefaultFailure); + } + + [Fact] + public void TheSetHoldsThirteenPoints() + { + Assert.Equal(13, GatePoint.All.Count); + Assert.Equal(13, GatePoint.All.Select(point => point.Name).Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void AHookThatOverridesNothingFailsTheDefaultWay() + { + Assert.Equal(HookFailure.Closed, new NothingHook().FailureFor(GatePoint.BeforeCall)); + Assert.Equal(HookFailure.Open, new NothingHook().FailureFor(GatePoint.BeforeTool)); + Assert.Equal(TimeSpan.FromSeconds(30), new NothingHook().NoticeTimeout); + } + + private sealed class NothingHook : AgentHook; + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs b/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs new file mode 100644 index 00000000..a1397942 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs @@ -0,0 +1,252 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class GateRunnerTests + { + private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 0, DateTimeOffset.UnixEpoch); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private sealed record Decision(bool Blocked, string Sku, int Seen); + + [Fact] + public async Task AModifyingVerbIsWhatTheNextHookSees() + { + List seen = []; + Hook first = new((gate, _) => { gate.ReplaceArguments(Args("B2")); return default; }); + Hook second = new((gate, _) => { seen.Add((string)gate.Arguments["sku"]!); return default; }); + + Decision result = await RunAsync([first, second], new FakeTimeProvider(DateTimeOffset.UnixEpoch), faults: null); + + Assert.Equal(["B2"], seen); + Assert.Equal("B2", result.Sku); + } + + [Fact] + public async Task ATerminalVerbEndsTheChain() + { + Hook first = new((gate, _) => { gate.Block("no"); return default; }); + Hook second = new((_, _) => throw new InvalidOperationException("must not run")); + + Decision result = await RunAsync([first, second], new FakeTimeProvider(DateTimeOffset.UnixEpoch), faults: null); + + Assert.True(result.Blocked); + Assert.Equal(0, second.Calls); + } + + // A hook that ignores its token is abandoned at the deadline; its view is sealed. + [Fact(Timeout = 10_000)] + public async Task AHookThatNeverReturnsIsAbandonedAtTheDeadlineAndItsLateVerbThrows() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource late = new(TaskCreationOptions.RunContinuationsAsynchronously); + Hook stuck = new(async (gate, _) => + { + await release.Task; + try { gate.Block("too late"); late.SetResult(null); } + catch (InvalidOperationException exception) { late.SetResult(exception); } + }); + Hook next = new((_, _) => default); + List<(Fault Fault, AgentHook Except)> faults = []; + + Task running = RunAsync([stuck, next], time, faults).AsTask(); + await time.WaitForTimersAsync(2); + time.Advance(GatePoint.BeforeTool.Deadline); + Decision result = await running; + release.SetResult(); + + Assert.False(result.Blocked); + Assert.Equal(1, next.Calls); + _ = Assert.IsType(await late.Task); + (Fault fault, AgentHook except) = Assert.Single(faults); + Assert.Equal(FaultKind.HookFailed, fault.Kind); + Assert.Same(stuck, except); + } + + // A hook that blocks its thread before its first await is abandoned at the deadline too. + [Fact(Timeout = 10_000)] + public async Task AHookThatBlocksItsThreadIsAbandonedAtTheDeadline() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + object gate = new(); + bool released = false; + Hook blocking = new((_, _) => + { + lock (gate) + { + while (!released) + { + _ = Monitor.Wait(gate); + } + } + + return default; + }); + Hook next = new((_, _) => default); + List<(Fault Fault, AgentHook Except)> faults = []; + + try + { + Task running = Task.Run(() => RunAsync([blocking, next], time, faults).AsTask(), Ct); + await time.WaitForTimersAsync(2).WaitAsync(TimeSpan.FromSeconds(5), Ct); + time.Advance(GatePoint.BeforeTool.Deadline); + _ = await running.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + Assert.Equal(1, next.Calls); + Assert.Same(blocking, Assert.Single(faults).Except); + } + finally + { + lock (gate) + { + released = true; + Monitor.PulseAll(gate); + } + } + } + + [Fact(Timeout = 10_000)] + public async Task AHookThatHonoursItsTokenSeesItCancelledAtTheDeadline() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + TaskCompletionSource cancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + Hook polite = new(async (_, token) => + { + try { await Task.Delay(Timeout.Infinite, token); } + catch (OperationCanceledException) { cancelled.SetResult(true); throw; } + }); + + using RecordingLoggerFactory logs = new(); + GateRunner runner = new(HookTable.Build([polite]), logs.CreateLogger("hooks"), time); + + Task running = Run(runner, faults: null, Ct).AsTask(); + await time.WaitForTimersAsync(2); + time.Advance(GatePoint.BeforeTool.Deadline); + _ = await running; + + Assert.True(await cancelled.Task); + _ = Assert.Single(logs.Of(201)); + Assert.Empty(logs.Of(200)); + } + + // Open drops the failing hook's staged verbs and goes on. + [Fact] + public async Task AThrowingHookFailsOpenByDefault() + { + Hook thrower = new((gate, _) => { gate.ReplaceArguments(Args("X9")); throw new InvalidOperationException("boom"); }); + Hook next = new((_, _) => default); + List<(Fault Fault, AgentHook Except)> faults = []; + + Decision result = await RunAsync([thrower, next], new FakeTimeProvider(DateTimeOffset.UnixEpoch), faults); + + Assert.Equal("A1", result.Sku); + Assert.Equal(1, next.Calls); + _ = Assert.Single(faults); + } + + // A hook may fail closed per point; the safe verb applies and the chain stops. + [Fact] + public async Task AHookThatFailsClosedAppliesTheSafeVerbAndStops() + { + Hook thrower = new((_, _) => throw new InvalidOperationException("boom")) { Failure = HookFailure.Closed }; + Hook next = new((_, _) => default); + + Decision result = await RunAsync([thrower, next], new FakeTimeProvider(DateTimeOffset.UnixEpoch), faults: null); + + Assert.True(result.Blocked); + Assert.Equal(0, next.Calls); + } + + // The hook's token carries the turn's cancellation, even after the runner stopped waiting. + [Fact(Timeout = 10_000)] + public async Task TheTurnsOwnCancellationIsNotAHookFailure() + { + using CancellationTokenSource turn = new(); + TaskCompletionSource hookCancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + Hook waiting = new(async (_, token) => + { + try { await Task.Delay(Timeout.Infinite, token); } + catch (OperationCanceledException) { hookCancelled.SetResult(); throw; } + }); + List<(Fault Fault, AgentHook Except)> faults = []; + GateRunner runner = new(HookTable.Build([waiting]), NullLogger.Instance, new FakeTimeProvider(DateTimeOffset.UnixEpoch)); + + ValueTask running = Run(runner, faults, turn.Token); + await turn.CancelAsync(); + + _ = await Assert.ThrowsAnyAsync(async () => await running); + await hookCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + Assert.Empty(faults); + } + + // A hook's own TimeoutException, thrown in time, is a failure with its stack, not a missed deadline. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AHookThatThrowsItsOwnTimeoutInTimeIsLoggedAsAFailure(bool afterAnAwait) + { + TimeoutException thrown = new("the hook's own backend timed out"); + Hook thrower = new(afterAnAwait + ? async (_, _) => { await Task.Yield(); throw thrown; } + : (_, _) => throw thrown); + using RecordingLoggerFactory logs = new(); + GateRunner runner = new( + HookTable.Build([thrower]), logs.CreateLogger("hooks"), new FakeTimeProvider(DateTimeOffset.UnixEpoch)); + + _ = await Run(runner, faults: null, Ct); + + Assert.Same(thrown, Assert.Single(logs.Of(200)).Exception); + Assert.Empty(logs.Of(201)); + } + + private static ValueTask RunAsync(Hook[] hooks, FakeTimeProvider time, List<(Fault, AgentHook)>? faults) + { + return Run(new GateRunner(HookTable.Build(hooks), NullLogger.Instance, time), faults, Ct); + } + + private static ValueTask Run(GateRunner runner, List<(Fault, AgentHook)>? faults, CancellationToken cancellationToken) + { + return runner.RunAsync( + GatePoint.BeforeTool, + Scope, + new Decision(false, "A1", 0), + state => new ToolGate(Scope, "lookup", "call-1", Args(state.Sku), new Dictionary()), + (hook, gate, token) => hook.BeforeToolAsync(gate, token), + (state, gate) => state with + { + Blocked = state.Blocked || gate.Blocked, + Sku = gate.NewArguments is { } args ? (string)args["sku"]! : state.Sku, + }, + state => state with { Blocked = true }, + faults is null ? null : (fault, except) => { lock (faults) { faults.Add((fault, except)); } }, + cancellationToken); + } + + private static Dictionary Args(string sku) => new(StringComparer.Ordinal) { ["sku"] = sku }; + + private sealed class Hook(Func body) : AgentHook + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public HookFailure? Failure { get; init; } + + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + _ = Interlocked.Increment(ref _calls); + return body(gate, cancellationToken); + } + + public override HookFailure FailureFor(GatePoint point) => Failure ?? base.FailureFor(point); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/GraphParticipantHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/GraphParticipantHookTests.cs new file mode 100644 index 00000000..96277d73 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/GraphParticipantHookTests.cs @@ -0,0 +1,141 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + /// + /// A graph row runs each participant on a session of its own, with no run options. Every turn-scoped + /// hook point and tool binding must still find the turn that runs the graph. + /// + public sealed class GraphParticipantHookTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Inside a graph: the researcher makes two rounds (the tool call, then its answer), the responder one. + [Fact] + public async Task ABeforeModelHookSeesEveryRoundOfEveryParticipant() + { + List<(string? Conversation, int? Turn, int Round)> seen = []; + BeforeGates hook = new(model: gate => seen.Add((gate.Scope.ConversationId, gate.Scope.TurnIndex, gate.Round))); + ConversationSession session = HookSessions.Create( + HookSessions.GraphYaml, new ToolCallingChatClient("final"), [hook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal([(session.ConversationId, 0, 0), (session.ConversationId, 0, 1), (session.ConversationId, 0, 2)], seen); + } + + [Fact] + public async Task AParticipantsToolCallPassesTheToolGateAndRaisesOneToolCalled() + { + List<(string Tool, string? Conversation, int? Turn)> gated = []; + BeforeGates hook = new(tool: gate => gated.Add((gate.ToolName, gate.Scope.ConversationId, gate.Scope.TurnIndex))); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.GraphYaml, new ToolCallingChatClient("final"), [hook, notices], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal([("price_lookup", session.ConversationId, 0)], gated); + ToolCalled called = Assert.Single(notices.Of()); + Assert.Equal(("price_lookup", 0, ToolOutcome.Ok), (called.ToolName, called.Scope.TurnIndex, called.Outcome)); + } + + [Fact] + public async Task ABoundToolInAGraphReadsTheConversationAndTurnFromItsScope() + { + List<(string ConversationId, int TurnIndex)?> seen = []; + ConversationSession session = HookSessions.Create( + HookSessions.GraphYaml, + new ToolCallingChatClient("final"), + tools: tool => AIFunctionFactory.Create( + (AIFunctionArguments arguments) => + { + seen.Add(TurnInvocation.FiledIn(arguments) is { } turn + ? (ToolCallScopes.From(turn).ConversationId, ToolCallScopes.From(turn).TurnIndex) + : null); + return "ok"; + }, + tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal([(session.ConversationId, 0)], seen); + } + + // A handoff row runs its participants with options of its own, which carry the handoff tool. The + // researcher hands off only if that tool still reaches the model, and the turn rides those options too. + [Fact] + public async Task AHandoffParticipantKeepsItsHandoffToolAndCarriesTheTurn() + { + List<(string AgentId, bool Nested, int? Turn)> seen = []; + BeforeGates hook = new(run: gate => seen.Add((gate.AgentId, gate.Nested, gate.Scope.TurnIndex))); + RequestsSeen model = new(new ScriptedToolCallingChatClient(("handoff_to_1", "{}")) { FinalText = "done" }); + ConversationSession session = HookSessions.Create(HookSessions.HandoffGraphYaml, model, [hook]); + + _ = await session.RunTurnAsync("hi", Ct); + + Assert.Equal([("researcher", true, 0), ("responder", true, 0)], seen); + Assert.Equal([session.ConversationId, session.ConversationId], model.Requests.Select(static request => request.Stamp)); + } + + // The compiled participants are shared by every conversation; each run must find its own conversation's turn. + [Fact] + public async Task TwoConversationsOnOneCompiledGraphEachSeeTheirOwnTurn() + { + List<(string? Conversation, string AgentId, int? Turn)> seen = []; + BeforeGates hook = new(run: gate => seen.Add((gate.Scope.ConversationId, gate.AgentId, gate.Scope.TurnIndex))); + CompiledAgent compiled = HookSessions.Compile(HookSessions.KeptGraphYaml, new ScriptedChatClient("done"), [hook])["main"]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + ConversationSession first = factory.Create("conversation-a"); + ConversationSession second = factory.Create("conversation-b"); + + _ = await first.RunTurnAsync("one", Ct); + _ = await second.RunTurnAsync("one", Ct); + _ = await first.RunTurnAsync("two", Ct); + + Assert.Equal( + [ + ("conversation-a", "researcher", 0), ("conversation-a", "responder", 0), + ("conversation-b", "researcher", 0), ("conversation-b", "responder", 0), + ("conversation-a", "researcher", 1), ("conversation-a", "responder", 1), + ], + seen); + } + + // The prompt cache key: every participant's request names the conversation, as a single agent's does. + [Fact] + public async Task EveryParticipantsRequestCarriesTheConversationId() + { + RequestsSeen model = new(new ScriptedChatClient("done")); + ConversationSession session = HookSessions.Create(HookSessions.GraphYaml, model, tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("hi", Ct); + + Assert.Equal([session.ConversationId, session.ConversationId], model.Requests.Select(static request => request.Stamp)); + } + + // A context note lands on the turn's runs, and on a graph row each participant's run is one. + [Fact] + public async Task ATurnNoteReachesEveryParticipantOnce() + { + RequestsSeen model = new(new ScriptedChatClient("done")); + BeforeGates hook = new(turn: gate => gate.AddContext("per-turn note")); + ConversationSession session = HookSessions.Create(HookSessions.GraphYaml, model, [hook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("hi", Ct); + + Assert.Equal([1, 1], model.Requests.Select(static request => request.Messages.Count(static text => text == "per-turn note"))); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs b/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs new file mode 100644 index 00000000..18159349 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs @@ -0,0 +1,61 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class HookGateLazyInputTests + { + private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 0, DateTimeOffset.UnixEpoch); + + private static RunGate Run() => new(Scope, "agent", nested: false, new Dictionary()); + + // The runner seals a view at the deadline even while an abandoned hook is still inside a verb. The + // verb stays blocked until the seal returned, so a seal that waited for it would only end at the bound. + [Fact(Timeout = 30_000)] + public async Task SealingDoesNotWaitForAHookBlockedInsideALazyInput() + { + CancellationToken token = TestContext.Current.CancellationToken; + RunGate gate = Run(); + TaskCompletionSource enumerating = new(TaskCreationOptions.RunContinuationsAsynchronously); + using ManualResetEventSlim release = new(); + Task verb = Task.Run(() => gate.AddMessages(Blocking(enumerating, release, token)), token); + await enumerating.Task.WaitAsync(token); + + Task sealing = Task.Run(gate.Seal, token); + Exception? sealFailure = await Record.ExceptionAsync(() => sealing.WaitAsync(TimeSpan.FromSeconds(20), token)); + release.Set(); + _ = await Assert.ThrowsAsync(() => verb); + + Assert.Null(sealFailure); + Assert.Empty(gate.Messages); + } + + // A verb is applied whole or not at all. + [Fact] + public void AnInputThatThrowsMidwayStagesNothing() + { + RunGate gate = Run(); + + _ = Assert.Throws(() => gate.AddMessages(ThrowingAfterOne())); + + Assert.Empty(gate.Messages); + Assert.False(gate.IsTerminal); + } + + private static IEnumerable Blocking(TaskCompletionSource enumerating, ManualResetEventSlim release, CancellationToken token) + { + yield return new ChatMessage(ChatRole.User, "first"); + _ = enumerating.TrySetResult(); + release.Wait(token); + yield return new ChatMessage(ChatRole.User, "second"); + } + + private static IEnumerable ThrowingAfterOne() + { + yield return new ChatMessage(ChatRole.User, "first"); + throw new FormatException("bad input"); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs new file mode 100644 index 00000000..551ba548 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs @@ -0,0 +1,112 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class HookGateTests + { + private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 0, DateTimeOffset.UnixEpoch); + + private static ToolGate Tool() => new( + Scope, + "price_lookup", + "call-1", + new Dictionary(StringComparer.Ordinal) { ["sku"] = "A1" }, + new Dictionary(StringComparer.Ordinal)); + + // Calling two terminal verbs throws. + [Fact] + public void TwoTerminalVerbsThrow() + { + ToolGate gate = Tool(); + gate.Block("no"); + + _ = Assert.Throws(() => gate.Respond("yes")); + Assert.True(gate.Blocked); + Assert.Equal("no", gate.Result); + } + + // A verb after a terminal verb throws. + [Fact] + public void AModifyingVerbAfterATerminalVerbThrows() + { + ToolGate gate = Tool(); + gate.Block("no"); + + _ = Assert.Throws(gate.EndLoop); + Assert.False(gate.EndsLoop); + } + + [Fact] + public void ModifyingVerbsBeforeATerminalVerbAllStand() + { + ToolGate gate = Tool(); + Dictionary patched = new(StringComparer.Ordinal) { ["sku"] = "B2" }; + + gate.ReplaceArguments(patched); + gate.EndLoop(); + gate.Respond("cached"); + + Assert.Same(patched, gate.NewArguments); + Assert.True(gate.EndsLoop); + Assert.True(gate.Responded); + Assert.True(gate.IsTerminal); + } + + // Once the chain moves past a hook its view is sealed; a late verb throws and changes nothing. + [Fact] + public void ASealedViewRefusesEveryVerb() + { + ToolGate gate = Tool(); + gate.Seal(); + + _ = Assert.Throws(() => gate.Block("late")); + Assert.False(gate.Blocked); + Assert.Null(gate.Result); + } + + // Retry is allowed only before the first stream update was yielded. + [Fact] + public void ModelRetryAfterAYieldedUpdateThrows() + { + ModelFailureGate gate = new(Scope, round: 0, attempt: 0, new InvalidOperationException("boom"), canRetry: false, new Dictionary()); + + _ = Assert.Throws(gate.Retry); + Assert.False(gate.Retrying); + } + + [Fact] + public void ModelRetryBeforeAnyUpdateStands() + { + ModelFailureGate gate = new(Scope, round: 0, attempt: 0, new InvalidOperationException("boom"), canRetry: true, new Dictionary()); + + gate.Retry(); + + Assert.True(gate.Retrying); + } + + [Fact] + public void AnApprovalDenialKeepsItsReason() + { + ApprovalGate gate = new(Scope, "transfer", "call-9", new Dictionary(), new Dictionary()); + + gate.Deny("over the limit"); + + Assert.False(gate.Approved); + Assert.Equal("over the limit", gate.DenyReason); + } + + [Fact] + public void AModelGateResponseIsTerminal() + { + ModelGate gate = new(Scope, round: 1, [new ChatMessage(ChatRole.User, "hi")], options: null, new Dictionary()); + + gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, "canned"))); + + Assert.True(gate.IsTerminal); + Assert.Equal("canned", gate.Response?.Text); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs b/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs new file mode 100644 index 00000000..6b90f0c4 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs @@ -0,0 +1,213 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Ports; +using AgentCore.Application.Skills; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// Builds sessions whose agents are compiled with hooks. + internal static class HookSessions + { + internal const string OneAgentYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + internal const string TwoEntryYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + other: + agent: only + """; + + internal const string ToolAgentYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "quote the price", tools: [ price_lookup ] } + entries: + main: + agent: only + """; + + internal const string EndsAfterTheFirstTurnYaml = """ + apiVersion: agentcore/v1 + guards: + afterFirst: { ">=": [ { var: turnIndex }, 1 ] } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: afterFirst } ] } + - { id: done, agent: only, terminal: true } + """; + + internal const string DelegatingYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: ask_helper, kind: agent, agent: helper, description: Ask the helper. } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "delegate", tools: [ ask_helper ] } + - { id: helper, instructions: "help" } + entries: + main: + agent: only + """; + + internal const string GraphYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + defaults: { clock: false } + items: + - { id: researcher, instructions: "look it up", tools: [ price_lookup ] } + - { id: responder, instructions: "answer" } + entries: + main: + graph: + pattern: sequential + agents: [ researcher, responder ] + """; + + /// A graph row that keeps one workflow session for the whole conversation (a harness switch is set). + internal const string KeptGraphYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: researcher, instructions: "look it up", todos: true } + - { id: responder, instructions: "answer" } + entries: + main: + graph: + pattern: sequential + agents: [ researcher, responder ] + """; + + internal const string ExplicitGraphYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: researcher, instructions: "look it up" } + - { id: responder, instructions: "answer" } + entries: + main: + graph: + nodes: + - { id: research, agent: researcher, start: true } + - { id: respond, agent: responder, output: true } + edges: + - { from: research, to: respond } + """; + + internal const string ConcurrentGraphYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: researcher, instructions: "look it up" } + - { id: responder, instructions: "answer" } + entries: + main: + graph: + pattern: concurrent + agents: [ researcher, responder ] + """; + + internal const string HandoffGraphYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: researcher, instructions: "hand off" } + - { id: responder, instructions: "answer" } + entries: + main: + graph: + pattern: handoff + agents: [ researcher, responder ] + """; + + internal const string BackgroundYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + agent: parent + """; + + internal static IReadOnlyDictionary Compile( + string yaml, + IChatClient model, + IReadOnlyList? hooks = null, + IConversationStore? store = null, + Func? tools = null, + TimeProvider? time = null, + SkillCatalog? skills = null) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + return ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(new FakeChatClientFactory(model)) + { + ConversationStore = store, + Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), + Hooks = hooks, + Clock = time, + Skills = skills, + }); + } + + internal static ConversationSession Create( + string yaml, + IChatClient model, + IReadOnlyList? hooks = null, + IConversationStore? store = null, + Func? tools = null, + string? conversationId = null, + TimeProvider? time = null, + SkillCatalog? skills = null) + { + CompiledAgent compiled = Compile(yaml, model, hooks, store, tools, time, skills)["main"]; + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards), timeProvider: time) + .Create(conversationId); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs b/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs new file mode 100644 index 00000000..285ae68e --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs @@ -0,0 +1,81 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class HookTableTests + { + [Fact] + public void AGateIsListedOnlyForTheHooksThatOverrideIt() + { + ToolOnly tool = new(); + NoticeOnly notice = new(); + + HookTable table = HookTable.Build([tool, notice]); + + Assert.Equal([tool], table.For(GatePoint.BeforeTool)); + Assert.Empty(table.For(GatePoint.BeforeModel)); + Assert.True(table.Overrides(GatePoint.BeforeTool)); + Assert.False(table.Overrides(GatePoint.AfterRun)); + } + + [Fact] + public void ANoticeIsWantedOnlyWhenSomeHookOverridesIt() + { + HookTable table = HookTable.Build([new ToolOnly(), new NoticeOnly()]); + + Assert.True(table.Wants(typeof(TurnCompleted))); + Assert.False(table.Wants(typeof(ModelCalled))); + Assert.Equal([typeof(TurnCompleted)], table.NoticesOf(table.NoticeHooks.Single()).ToArray()); + } + + // Hooks run in registration order; AgentCore puts its built-ins first in that list. + [Fact] + public void HooksKeepTheirRegistrationOrder() + { + ToolOnly first = new(); + ToolOnly second = new(); + + Assert.Equal([first, second], HookTable.Build([first, second]).For(GatePoint.BeforeTool)); + } + + [Fact] + public void AnInstanceRegisteredTwiceIsOneHook() + { + ToolOnly hook = new(); + + HookTable table = HookTable.Build([hook, hook]); + + _ = Assert.Single(table.For(GatePoint.BeforeTool)); + _ = Assert.Single(table.Hooks); + } + + [Fact] + public void AMethodHiddenWithNewIsNotAnOverride() + { + HookTable table = HookTable.Build([new Hiding()]); + + Assert.Empty(table.For(GatePoint.BeforeTool)); + Assert.False(table.OverridesAnyGate(table.Hooks[0])); + } + + private sealed class ToolOnly : AgentHook + { + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + } + + private sealed class NoticeOnly : AgentHook + { + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => default; + } + + private sealed class Hiding : AgentHook + { + public new ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => + throw new InvalidOperationException($"{GetType().Name} hides the gate method, so no gate calls it."); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/LoggingHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/LoggingHookTests.cs new file mode 100644 index 00000000..fa46b900 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/LoggingHookTests.cs @@ -0,0 +1,111 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class LoggingHookTests + { + // EventIds from Diagnostics/Log.cs: ExtractionFailed 1, ToolBudgetSpent 2, EmptyReply 3, PromptRefused 6, + // ModerationUnavailable 7, TranscriptWriteFailed 9, StateRestorePartial 13, TranscriptResyncFailed 19, + // TurnRunFaulted 28, TurnRefused 40. + private static readonly HookScope Scope = new("c1", "main", 4, "", Guid.CreateVersion7(), 1, DateTimeOffset.UnixEpoch); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AnEmptyTurnIsTheEmptyReplyLine() + { + RecordingLoggerFactory logs = new(); + await new LoggingHook(logs.CreateLogger("t")).OnTurnCompletedAsync( + new TurnCompleted(Scope, TurnOutcome.Empty, "secret words", "", "", "", TimeSpan.Zero, TurnFailureReasons.EmptyReply, false, null), Ct); + + CapturedLine line = Assert.Single(logs.Of(3)); + Assert.Equal(4, line.Field("TurnIndex")); + Assert.DoesNotContain("secret words", line.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AToolFallbackIsTheToolBudgetLineAndARunFallbackIsTheRunFaultLine() + { + RecordingLoggerFactory logs = new(); + LoggingHook hook = new(logs.CreateLogger("t")); + InvalidOperationException toolFault = new("the tool spent its budget"); + InvalidOperationException runFault = new("the run threw"); + + await hook.OnTurnCompletedAsync(new TurnCompleted(Scope, TurnOutcome.Fallback, "u", "r", "", "", TimeSpan.Zero, "why", true, toolFault), Ct); + await hook.OnTurnCompletedAsync(new TurnCompleted(Scope, TurnOutcome.Fallback, "u", "r", "", "", TimeSpan.Zero, "why", false, runFault), Ct); + + Assert.Same(toolFault, Assert.Single(logs.Of(2)).Exception); + Assert.Same(runFault, Assert.Single(logs.Of(28)).Exception); + } + + [Fact] + public async Task AnExtractionFaultIsTheExtractionLine() + { + RecordingLoggerFactory logs = new(); + await new LoggingHook(logs.CreateLogger("t")).OnFaultAsync(new Fault(Scope, FaultKind.ExtractionFailed, "the object was invalid", null), Ct); + + Assert.Equal("the object was invalid", Assert.Single(logs.Of(1)).Field("Reason")); + } + + // HookFailed is logged once by the engine itself; the logging hook does not log it again. + [Fact] + public async Task AHookFaultIsNotLoggedTwice() + { + RecordingLoggerFactory logs = new(); + await new LoggingHook(logs.CreateLogger("t")).OnFaultAsync(new Fault(Scope, FaultKind.HookFailed, "x failed", null), Ct); + + Assert.Empty(logs.Lines); + } + + [Fact] + public async Task ARefusalIsTheTurnRefusedLineWithItsToken() + { + RecordingLoggerFactory logs = new(); + await new LoggingHook(logs.CreateLogger("t")).OnTurnRefusedAsync(new TurnRefused(Scope, TurnRefusal.InUse, AfterEnd: false), Ct); + + Assert.Equal("in_use", Assert.Single(logs.Of(40)).Field("Reason")); + } + + [Fact] + public async Task AFlaggedInputIsThePromptRefusedLineAndAnUnavailableOneSaysWhy() + { + RecordingLoggerFactory logs = new(); + LoggingHook hook = new(logs.CreateLogger("t")); + + await hook.OnInputModeratedAsync(new InputModerated(Scope, InputVerdict.Flagged, ["violence", "harassment"], null), Ct); + await hook.OnInputModeratedAsync(new InputModerated(Scope, InputVerdict.Unavailable, [], ModerationUnavailableReason.TimedOut), Ct); + + Assert.Equal("violence,harassment", Assert.Single(logs.Of(6)).Field("Categories")); + Assert.Equal(TurnFailureReasons.ModerationTimedOut, Assert.Single(logs.Of(7)).Field("Reason")); + } + + [Fact] + public async Task AStateRestoreFaultIsTheStateRestoreLine() + { + RecordingLoggerFactory logs = new(); + await new LoggingHook(logs.CreateLogger("t")).OnFaultAsync(new Fault(Scope, FaultKind.StateRestorePartial, "the document no longer declares the slot 'retired'.", null), Ct); + + Assert.Equal("the document no longer declares the slot 'retired'.", Assert.Single(logs.Of(13)).Field("Reason")); + } + + [Fact] + public async Task StoreFaultsAreTheTranscriptLinesWithTheirExceptions() + { + RecordingLoggerFactory logs = new(); + LoggingHook hook = new(logs.CreateLogger("t")); + IOException write = new("disk full"); + IOException read = new("disk gone"); + + await hook.OnFaultAsync(new Fault(Scope, FaultKind.TranscriptWriteFailed, "IOException: disk full", write), Ct); + await hook.OnFaultAsync(new Fault(Scope, FaultKind.TranscriptResyncFailed, "IOException: disk gone", read), Ct); + + Assert.Same(write, Assert.Single(logs.Of(9)).Exception); + Assert.Same(read, Assert.Single(logs.Of(19)).Exception); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ModelCalledNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ModelCalledNoticeTests.cs new file mode 100644 index 00000000..cf83ff3e --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ModelCalledNoticeTests.cs @@ -0,0 +1,82 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + /// One per round trip to the model, on both paths of the model layer. + public sealed class ModelCalledNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A round an AfterModelFailed hook answered still failed at the model, so its notice says how. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ARoundAFailureHookAnsweredCarriesTheModelsFailure(bool streaming) + { + FlakyModel model = new(_ => FlakyModel.Fault.BeforeFirstUpdate); + RecordingHook notices = new(); + + string reply = await RunAsync(streaming, model, [new Responder(), notices]); + + Assert.Equal("from the hook", reply); + ModelCalled called = Assert.Single(notices.Of()); + Assert.Equal("InvalidOperationException: model down", called.Failure); + } + + // A round a BeforeModel hook answered never reached the model: no round trip, no notice. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ARoundABeforeModelHookAnsweredIsNoModelCall(bool streaming) + { + FlakyModel model = new(_ => FlakyModel.Fault.None); + RecordingHook notices = new(); + ModelGateTests.BeforeOnly answering = new(gate => gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, "canned")))); + + string reply = await RunAsync(streaming, model, [answering, notices]); + + Assert.Equal("canned", reply); + Assert.Equal(0, model.Attempts); + Assert.Empty(notices.Of()); + } + + private static async Task RunAsync(bool streaming, IChatClient model, AgentHook[] hooks) + { + if (streaming) + { + ConversationSession conversation = HookSessions.Create(HookSessions.OneAgentYaml, model, hooks); + string text = (await conversation.RunTurnAsync("hi", Ct)).ReplyText; + await conversation.FlushNoticesAsync(); + return text; + } + + await using HookRuntime runtime = HookRuntime.Create(hooks, loggers: null); + ChatClientAgent agent = new(new ModelHookChatClient(model, runtime), new ChatClientAgentOptions { Name = "bare" }); + SessionHooks session = new(runtime, "c1", "main", TimeProvider.System); + TurnInvocation turn = new() { ConversationId = "c1", TurnIndex = 0, Stage = string.Empty, Hooks = session, Rounds = new TurnRounds() }; + string reply = (await agent.RunAsync("hi", options: turn.RunOptions(), cancellationToken: Ct)).Text; + await session.FlushAsync(); + session.Release(); + return reply; + } + + private sealed class Responder : AgentHook + { + public override ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) + { + gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, "from the hook"))); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ModelFailureGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ModelFailureGateTests.cs new file mode 100644 index 00000000..41074700 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ModelFailureGateTests.cs @@ -0,0 +1,163 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ModelFailureGateTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A throw before any update is retried once; the caller gets the text exactly once. + [Fact] + public async Task AThrowBeforeAnyUpdateIsRetriedCleanly() + { + FlakyModel model = new(attempt => attempt == 0 ? FlakyModel.Fault.BeforeFirstUpdate : FlakyModel.Fault.None); + Retrier hook = new(); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [hook]); + + string text = string.Empty; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", Ct)) + { + text += update.Text; + } + + Assert.Equal("final text", text); + Assert.Equal(2, model.Attempts); + Assert.Equal([true], hook.CanRetry); + } + + // After one yielded update a retry would duplicate text ("finalfinal text"), so Retry throws and + // the hook fails open: the fault reaches the turn. + [Fact] + public async Task ARetryAfterAYieldedUpdateIsRefused() + { + FlakyModel model = new(_ => FlakyModel.Fault.AfterFirstUpdate); + Retrier hook = new(); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [hook, notices]); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(1, model.Attempts); + Assert.Equal([false], hook.CanRetry); + Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); + Assert.Contains(notices.Of(), fault => fault.Kind == FaultKind.HookFailed); + Assert.NotNull(Assert.Single(notices.Of()).Failure); + } + + // Retries are capped at 2 per call. + [Fact] + public async Task RetryIsCappedAtTwo() + { + FlakyModel model = new(_ => FlakyModel.Fault.BeforeFirstUpdate); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [new Retrier()]); + + _ = await session.RunTurnAsync("hi", Ct); + + Assert.Equal(3, model.Attempts); + } + + // After part of the round reached the caller, the hook's answer follows it as + // its own message, the shape the fallback gives a dropped stream: assistant "final", then the fallback line. + [Fact] + public async Task AnAnswerAfterAPartialStreamIsItsOwnMessage() + { + FlakyModel model = new(attempt => attempt == 0 ? FlakyModel.Fault.AfterFirstUpdate : FlakyModel.Fault.None); + RequestCapturingChatClient capture = new(model); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, capture, [new Responder()]); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + _ = await session.RunTurnAsync("again", Ct); + + Assert.Equal("from the hook", turn.ReplyText); + Assert.Equal(["final", "from the hook"], capture.Requests[^1].Where(message => message.Role == ChatRole.Assistant).Select(message => message.Text)); + } + + // CanRetry is false on the last attempt the cap allows, so a hook that checks it + // reaches its fallback answer. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CanRetryIsFalseOnTheLastAttempt(bool streaming) + { + FlakyModel model = new(_ => FlakyModel.Fault.BeforeFirstUpdate); + GivingUp hook = new(); + + string reply; + if (streaming) + { + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [hook]); + reply = (await session.RunTurnAsync("hi", Ct)).ReplyText; + } + else + { + await using HookRuntime runtime = HookRuntime.Create([hook], loggers: null); + ChatClientAgent agent = new(new ModelHookChatClient(model, runtime), new ChatClientAgentOptions { Name = "bare" }); + SessionHooks session = new(runtime, "c1", "main", TimeProvider.System); + TurnInvocation turn = new() { ConversationId = "c1", TurnIndex = 0, Stage = string.Empty, Hooks = session, Rounds = new TurnRounds() }; + reply = (await agent.RunAsync("hi", options: turn.RunOptions(), cancellationToken: Ct)).Text; + session.Release(); + } + + Assert.Equal([true, true, false], hook.CanRetry); + Assert.Equal("give up", reply); + Assert.Equal(3, model.Attempts); + } + + private sealed class Retrier : AgentHook + { + public List CanRetry { get; } = []; + + public override ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) + { + if (gate.Attempt == 0) + { + CanRetry.Add(gate.CanRetry); + } + + gate.Retry(); + return default; + } + } + + private sealed class GivingUp : AgentHook + { + public List CanRetry { get; } = []; + + public override ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) + { + CanRetry.Add(gate.CanRetry); + if (gate.CanRetry) + { + gate.Retry(); + } + else + { + gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, "give up"))); + } + + return default; + } + } + + private sealed class Responder : AgentHook + { + public override ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) + { + gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, "from the hook"))); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs new file mode 100644 index 00000000..88903047 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs @@ -0,0 +1,281 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ModelGateTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The layer names the conversation and turn on every round, streaming or not. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ABeforeModelHookSeesEveryRoundOfItsTurn(bool streaming) + { + List<(string? Conversation, int? Turn, int Round)> seen = []; + Gates hook = new(before: gate => seen.Add((gate.Scope.ConversationId, gate.Scope.TurnIndex, gate.Round))); + ToolCallingChatClient model = new("final"); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [hook], tools: new StubToolBuilder("{}").Create); + + if (streaming) + { + await foreach (ChatResponseUpdate _ in session.RunTurnStreamingAsync("how much?", Ct)) + { + } + } + else + { + _ = await session.RunTurnAsync("how much?", Ct); + } + + Assert.Equal([(session.ConversationId, 0, 0), (session.ConversationId, 0, 1)], seen); + } + + [Fact] + public async Task ReplacedMessagesAreWhatTheModelSees() + { + ScriptedChatClient reply = new("ok"); + RequestCapturingChatClient capture = new(reply); + Gates hook = new(before: gate => gate.ReplaceMessages([.. gate.Messages, new ChatMessage(ChatRole.System, "be brief")])); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, capture, [hook]); + + _ = await session.RunTurnAsync("hi", Ct); + + Assert.Contains(capture.Requests[0], message => message.Text == "be brief"); + } + + // Buffering a streamed round keeps the tool loop and the text. + [Fact] + public async Task AnAfterModelHookKeepsTheToolLoopOfAStreamedTurn() + { + StubToolBuilder stub = new("""{ "price": 50 }"""); + ToolCallingChatClient model = new("final"); + Gates hook = new(after: _ => { }); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [hook], tools: stub.Create); + + string text = string.Empty; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("how much?", Ct)) + { + text += update.Text; + } + + Assert.Equal(["price_lookup"], stub.Called); + Assert.Equal("final", text); + } + + [Fact] + public async Task AnAfterModelHookReplacesTheAnswer() + { + ScriptedChatClient reply = new("original"); + Gates hook = new(after: gate => gate.ReplaceResponse(new ChatResponse(new ChatMessage(ChatRole.Assistant, "rewritten")))); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + + Assert.Equal("rewritten", turn.ReplyText); + } + + // One notice per round trip, with the model's usage. + [Fact] + public async Task EachRoundIsOneModelCalledNoticeWithItsUsage() + { + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, new UsageModel(), [notices]); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + ModelCalled called = Assert.Single(notices.Of()); + Assert.Equal(("test-model", 0, 11L, 3L, 5L, "stop"), (called.ModelId, called.Round, called.InputTokens, called.OutputTokens, called.CachedInputTokens, called.FinishReason)); + Assert.Null(called.Failure); + } + + // MAF mutates run options in place and LoopAgent reuses one options object, so a layer added per run + // would stack. Each run here is a tool round and a text round: four rounds, four notices. + [Fact] + public async Task TwoRunsWithOneOptionsObjectAreOneNoticePerRound() + { + RecordingHook notices = new(); + await using HookRuntime runtime = HookRuntime.Create([notices], loggers: null); + AIFunction tool = AIFunctionFactory.Create(() => "50", "price_lookup"); + ToolCallingChatClient model = new("final"); + ChatClientAgent inner = new( + new ModelHookChatClient(model, runtime), + new ChatClientAgentOptions { ChatOptions = new ChatOptions { Tools = [tool] } }); + AIAgent agent = ToolHookMiddleware.Apply(inner, runtime); + SessionHooks session = new(runtime, "c1", "main", TimeProvider.System); + TurnInvocation turn = new() + { + ConversationId = "c1", + TurnIndex = 0, + Stage = string.Empty, + Hooks = session, + Rounds = new TurnRounds(), + }; + AgentRunOptions options = turn.RunOptions(); + + _ = await agent.RunAsync("how much?", session: null, options, Ct); + _ = await agent.RunAsync("how much?", session: null, options, Ct); + await session.FlushAsync(); + session.Release(); + + Assert.Equal(4, model.Calls); + Assert.Equal([0, 1, 2, 3], notices.Of().Select(called => called.Round)); + } + + // An unbuffered round (no AfterModel hook) still replays a before-model hook's answer to the caller. + [Fact] + public async Task AnUnbufferedRespondStreamsTheHooksText() + { + ToolCallingChatClient model = new("never"); + BeforeOnly hook = new(gate => gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, "canned")))); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [hook]); + + string text = string.Empty; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", Ct)) + { + text += update.Text; + } + + Assert.Equal("canned", text); + Assert.Equal(0, model.Calls); + } + + // A before-model hook's answer that calls a tool reaches the tool loop: the tool runs, then the model answers the next round. + [Fact] + public async Task AnUnbufferedRespondCanCallATool() + { + StubToolBuilder stub = new("""{ "price": 50 }"""); + ToolCallingChatClient model = new("final"); + BeforeOnly hook = new(gate => + { + if (gate.Round == 0) + { + gate.Respond(new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "price_lookup")]))); + } + }); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [hook], tools: stub.Create); + + string text = string.Empty; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("how much?", Ct)) + { + text += update.Text; + } + + Assert.Equal(["price_lookup"], stub.Called); + Assert.Equal(1, model.Calls); + Assert.Equal("final", text); + } + + // A hook that fails closed before the model refuses the round with the entry's refusal reply, and the model is not called. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ABeforeModelHookThatFailsClosedRefusesTheRoundWithoutCallingTheModel(bool streaming) + { + ToolCallingChatClient model = new("never"); + ConversationSession session = HookSessions.Create(RefusingYaml, model, [new ClosedModelHook(before: true)]); + + string text = await ReplyAsync(session, streaming); + + Assert.Equal("not through this entry.", text); + Assert.Equal(0, model.Calls); + } + + // A hook that fails closed after the model replaces the model's answer with the refusal reply. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AnAfterModelHookThatFailsClosedReplacesTheAnswerWithTheRefusalReply(bool streaming) + { + ToolCallingChatClient model = new("the model's own answer"); + ConversationSession session = HookSessions.Create(RefusingYaml, model, [new ClosedModelHook(before: false)]); + + string text = await ReplyAsync(session, streaming); + + Assert.Equal("not through this entry.", text); + Assert.Equal(1, model.Calls); + } + + private const string RefusingYaml = """ + apiVersion: agentcore/v1 + refusalReply: "not through this entry." + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + private static async Task ReplyAsync(ConversationSession session, bool streaming) + { + if (!streaming) + { + return (await session.RunTurnAsync("hi", Ct)).ReplyText; + } + + string text = string.Empty; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", Ct)) + { + text += update.Text; + } + + return text; + } + + private sealed class ClosedModelHook(bool before) : AgentHook + { + public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => + before ? throw new InvalidOperationException("boom") : default; + + public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => + before ? default : throw new InvalidOperationException("boom"); + } + + internal sealed class BeforeOnly(Action before) : AgentHook + { + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) + { + before(gate); + return default; + } + } + + internal sealed class Gates(Action? before = null, Action? after = null) : AgentHook + { + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) + { + before?.Invoke(gate); + return default; + } + + public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) + { + if (after is null) + { + return default; + } + + after(gate); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ModerationNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ModerationNoticeTests.cs new file mode 100644 index 00000000..6978352a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ModerationNoticeTests.cs @@ -0,0 +1,94 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Evaluation; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Evaluation.Fakes; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ModerationNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The verdict is a notice stamped when moderation decided, before the turn completes. + [Fact] + public async Task AFlaggedInputIsNamedBeforeItsBlockedTurnCompletes() + { + RecordingHook hook = new(); + ConversationSession session = Create(hook, ScriptedModerationEvaluator.Flagging("violence", "harassment")); + + _ = await session.RunTurnAsync("something awful", Ct); + await session.FlushNoticesAsync(); + + InputModerated moderated = Assert.Single(hook.Of()); + TurnCompleted completed = Assert.Single(hook.Of()); + Assert.Equal(InputVerdict.Flagged, moderated.Verdict); + Assert.Equal(["violence", "harassment"], moderated.Categories); + Assert.Null(moderated.Reason); + Assert.Equal(TurnOutcome.Blocked, completed.Outcome); + Assert.True(moderated.Scope.Sequence < completed.Scope.Sequence); + Assert.Equal(0, moderated.Scope.TurnIndex); + } + + [Fact] + public async Task ACleanInputIsNamedClean() + { + RecordingHook hook = new(); + ConversationSession session = Create(hook, ScriptedModerationEvaluator.Clean()); + + _ = await session.RunTurnAsync("hello", Ct); + await session.FlushNoticesAsync(); + + InputModerated moderated = Assert.Single(hook.Of()); + Assert.Equal(InputVerdict.Clean, moderated.Verdict); + Assert.Empty(moderated.Categories); + Assert.Equal(TurnOutcome.Answered, hook.Of().Single().Outcome); + } + + // Unavailable carries why (TimedOut or Threw). + [Fact] + public async Task AModeratorThatThrowsIsUnavailableBecauseItThrew() + { + RecordingHook hook = new(); + ConversationSession session = Create(hook, ScriptedModerationEvaluator.Throwing(new InvalidOperationException("down"))); + + _ = await session.RunTurnAsync("hello", Ct); + await session.FlushNoticesAsync(); + + InputModerated moderated = Assert.Single(hook.Of()); + Assert.Equal((InputVerdict.Unavailable, ModerationUnavailableReason.Threw), (moderated.Verdict, moderated.Reason)); + } + + // ModerationAgent.DefaultTimeout is 2 s of wall time and the agent has no TimeProvider seam, so this test takes about 2 s. + [Fact(Timeout = 30_000)] + public async Task AModeratorThatPassesItsDeadlineIsUnavailableBecauseItTimedOut() + { + RecordingHook hook = new(); + ConversationSession session = Create(hook, ScriptedModerationEvaluator.Slow(TimeSpan.FromSeconds(10))); + + _ = await session.RunTurnAsync("hello", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(ModerationUnavailableReason.TimedOut, Assert.Single(hook.Of()).Reason); + } + + private static ConversationSession Create(RecordingHook hook, ScriptedModerationEvaluator moderation) + { + ScriptedChatClient reply = new("an answer"); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(HookSessions.OneAgentYaml), + new AgentCompilationContext(new FakeChatClientFactory(reply)) + { + Moderation = new PromptModerator(moderation), + Hooks = [hook], + })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs new file mode 100644 index 00000000..ec20a3a2 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + [Collection(NoticeHubCostSuite.Name)] + public sealed class NoticeHubCostTests + { + // The target is under 1 µs per raise. This host measured about 3.4 µs, most of it the + // version 7 Guid's clock read on an hpet clocksource, so the target is relaxed. The bound + // is 100 µs so the test stays a gate on a loaded machine; the measured mean is written to the output. + [Fact(Timeout = 60_000)] + [Trait("Kind", "Timing")] + public async Task ARaiseCostsMicrosecondsNotMilliseconds() + { + const int Count = 100_000; + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + NoticeHub hub = new(HookTable.Build([new Discarder()]), NullLogger.Instance, TimeProvider.System); + _ = hub.Raise(NoticeProbes.Started(), clock); + + long started = Stopwatch.GetTimestamp(); + for (int index = 0; index < Count; index++) + { + _ = hub.Raise(NoticeProbes.Started(index), clock); + } + + double meanMicroseconds = Stopwatch.GetElapsedTime(started).TotalMicroseconds / Count; + TestContext.Current.TestOutputHelper?.WriteLine($"mean Raise cost: {meanMicroseconds:0.000} µs"); + await hub.FlushAsync("c1"); + + Assert.True(meanMicroseconds < 100, $"mean Raise cost was {meanMicroseconds:0.000} µs"); + } + + /// Takes every turn start and keeps nothing, so the measurement holds no growing list. + private sealed class Discarder : AgentHook + { + public override TimeSpan? NoticeTimeout => null; + + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + } + + /// The collection runs in, alone, so no other test shares its clock time. + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class NoticeHubCostSuite + { + /// The name both the definition and the test class name it by. + public const string Name = "notice hub cost"; + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubLifetimeTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubLifetimeTests.cs new file mode 100644 index 00000000..47731df1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubLifetimeTests.cs @@ -0,0 +1,248 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class NoticeHubLifetimeTests + { + private static readonly FakeTimeProvider Clock = new(DateTimeOffset.UnixEpoch); + + // After ConversationEnded, only notices of a turn admitted before the end get through. + // The unload and a refusal are lifecycle facts and still do ("unload and end drop nothing"). + [Fact(Timeout = 10_000)] + public async Task AfterTheEndOnlyTurnsAdmittedBeforeItGetThrough() + { + Guid session = Guid.CreateVersion7(); + Recorder recorder = new(); + NoticeHub hub = new(HookTable.Build([recorder]), NullLogger.Instance, TimeProvider.System); + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + + _ = hub.Raise(new ConversationEnded(NoticeProbes.Scope(turn: null, session: session), ConversationEndReason.CallerHungUp, null, null), clock, endsAfterTurn: 2); + _ = hub.Raise(NoticeProbes.Started(2, session), clock); + _ = hub.Raise(NoticeProbes.Started(3, session), clock); + _ = hub.Raise(new ConversationUnloaded(NoticeProbes.Scope(turn: null, session: session), UnloadCause.Closed), clock); + _ = hub.Raise(new TurnRefused(NoticeProbes.Scope(turn: null, session: session), TurnRefusal.Terminal, AfterEnd: true), clock); + _ = hub.Raise(NoticeProbes.Started(3, Guid.CreateVersion7()), clock); + await hub.FlushAsync("c1"); + + Assert.Equal( + ["ConversationEnded:", "TurnStarted:2", "ConversationUnloaded:", "TurnRefused:", "TurnStarted:3"], + recorder.Seen.Select(notice => $"{notice.GetType().Name}:{notice.Scope.TurnIndex}")); + Assert.NotEqual(session, recorder.Seen[^1].Scope.SessionId); + } + + // A hook that fails on ConversationEnded raises its Fault after the end; the other hooks must still hear it. + [Fact(Timeout = 10_000)] + public async Task AFaultRaisedAfterTheEndStillReachesTheOtherHooks() + { + Guid session = Guid.CreateVersion7(); + Recorder recorder = new(); + NoticeHub hub = new(HookTable.Build([new Thrower(), recorder]), NullLogger.Instance, TimeProvider.System); + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + + _ = hub.Raise(new ConversationEnded(NoticeProbes.Scope(turn: null, session: session), ConversationEndReason.CallerHungUp, null, null), clock, endsAfterTurn: -1); + await hub.FlushAsync("c1"); + await hub.FlushAsync("c1"); + + Assert.Equal([nameof(ConversationEnded), nameof(Fault)], recorder.Seen.Select(notice => notice.GetType().Name)); + Assert.Equal(FaultKind.HookFailed, recorder.Seen.OfType().Single().Kind); + } + + // A Fault inside a turn follows its turn's admission; only a Fault about the conversation is exempt. + [Fact(Timeout = 10_000)] + public async Task AFaultAfterTheEndGetsThroughOnlyForTheConversationOrAnAdmittedTurn() + { + Guid session = Guid.CreateVersion7(); + Recorder recorder = new(); + NoticeHub hub = new(HookTable.Build([recorder]), NullLogger.Instance, TimeProvider.System); + + _ = hub.Raise(new ConversationEnded(NoticeProbes.Scope(turn: null, session: session), ConversationEndReason.CallerHungUp, null, null), Clock, endsAfterTurn: 2); + _ = hub.Raise(new Fault(NoticeProbes.Scope(turn: 3, session: session), FaultKind.HookFailed, "late turn", null), Clock); + _ = hub.Raise(new Fault(NoticeProbes.Scope(turn: null, session: session), FaultKind.HookFailed, "conversation", null), Clock); + _ = hub.Raise(new Fault(NoticeProbes.Scope(turn: 2, session: session), FaultKind.HookFailed, "admitted turn", null), Clock); + await hub.FlushAsync("c1"); + + Assert.Equal(["conversation", "admitted turn"], recorder.Seen.OfType().Select(fault => fault.Message)); + } + + // An end no hook hears still closes the conversation to later turns, and makes no mailbox of its own. + [Fact(Timeout = 10_000)] + public async Task AnEndNoHookWantsStillHoldsBackLaterTurnsWithoutMakingAMailbox() + { + Guid session = Guid.CreateVersion7(); + TurnsOnly hook = new(); + NoticeHub hub = new(HookTable.Build([hook]), NullLogger.Instance, TimeProvider.System); + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + + _ = hub.Raise(new ConversationEnded(NoticeProbes.Scope("c2", turn: null, session), ConversationEndReason.CallerHungUp, null, null), clock, endsAfterTurn: -1); + Assert.Equal(0, hub.MailboxCount); + + _ = hub.Raise(NoticeProbes.Started(0, session), clock); + _ = hub.Raise(new ConversationEnded(NoticeProbes.Scope(turn: null, session: session), ConversationEndReason.CallerHungUp, null, null), clock, endsAfterTurn: 0); + _ = hub.Raise(NoticeProbes.Started(1, session), clock); + await hub.FlushAsync("c1"); + + Assert.Equal([0], hook.Turns); + Assert.Equal(1, hub.MailboxCount); + } + + // An idle reader removes its mailbox; a later raise makes a new one and the counter restarts. + [Fact(Timeout = 10_000)] + public async Task AnIdleMailboxIsEvictedAndTheNextRaiseStartsANewOne() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + Recorder recorder = new(); + NoticeHub hub = new(HookTable.Build([recorder]), NullLogger.Instance, time); + + _ = hub.Raise(NoticeProbes.Started(0), time); + _ = hub.Raise(NoticeProbes.Started(1), time); + await hub.FlushAsync("c1"); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + await NoticeProbes.WaitUntilAsync(() => hub.MailboxCount == 0); + + _ = hub.Raise(NoticeProbes.Started(2), time); + await hub.FlushAsync("c1"); + + Assert.Equal([1L, 2L, 1L], recorder.Seen.Select(notice => notice.Scope.Sequence)); + } + + // A loaded session pins its mailbox, so idling does not restart its Sequence. + [Fact(Timeout = 10_000)] + public async Task APinnedMailboxOutlivesItsIdleTimeAndKeepsCounting() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + Recorder recorder = new(); + NoticeHub hub = new(HookTable.Build([recorder]), NullLogger.Instance, time); + + hub.Pin("c1"); + _ = hub.Raise(NoticeProbes.Started(0), time); + _ = hub.Raise(NoticeProbes.Started(1), time); + await hub.FlushAsync("c1"); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + // The refused eviction shows as the reader arming its next idle wait. + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + + _ = hub.Raise(NoticeProbes.Started(2), time); + await hub.FlushAsync("c1"); + + Assert.Equal(1, hub.MailboxCount); + Assert.Equal([1L, 2L, 3L], recorder.Seen.Select(notice => notice.Scope.Sequence)); + } + + [Fact(Timeout = 10_000)] + public async Task AReleasedMailboxIsEvictedOnceItsReaderIdles() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + NoticeHub hub = new(HookTable.Build([new Recorder()]), NullLogger.Instance, time); + + hub.Pin("c1"); + hub.Pin("c1"); + _ = hub.Raise(NoticeProbes.Started(0), time); + await hub.FlushAsync("c1"); + hub.Release("c1"); + hub.Release("c1"); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + + await NoticeProbes.WaitUntilAsync(() => hub.MailboxCount == 0); + } + + // A mailbox that never started a reader has no idle timer to evict it, so the last release does. + [Fact] + public void AMailboxPinnedButNeverWrittenGoesAtTheLastRelease() + { + NoticeHub hub = new(HookTable.Build([new Recorder()]), NullLogger.Instance, TimeProvider.System); + + hub.Pin("c1"); + hub.Pin("c1"); + hub.Release("c1"); + Assert.Equal(1, hub.MailboxCount); + + hub.Release("c1"); + Assert.Equal(0, hub.MailboxCount); + } + + // Every channel is completed and drained; nothing raised after the stop is delivered. + [Fact(Timeout = 10_000)] + public async Task StopDrainsWhatWasRaisedAndRefusesWhatComesAfter() + { + Recorder recorder = new() { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + NoticeHub hub = new(HookTable.Build([recorder]), NullLogger.Instance, TimeProvider.System); + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + + _ = hub.Raise(NoticeProbes.Started(0), clock); + _ = hub.Raise(NoticeProbes.Started(1), clock); + ValueTask stopping = hub.StopAsync(TimeSpan.FromSeconds(5)); + recorder.Hold.SetResult(); + await stopping; + _ = hub.Raise(NoticeProbes.Started(2), clock); + + Assert.Equal([0, 1], recorder.Seen.Select(notice => notice.Scope.TurnIndex ?? -1)); + } + + // A host shutdown timeout too long for a timer means no limit; a negative one means do not wait. + [Fact(Timeout = 10_000)] + public async Task AStopLongerThanATimerCanWaitStillDrains() + { + Recorder recorder = new() { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + NoticeHub hub = new(HookTable.Build([recorder]), NullLogger.Instance, TimeProvider.System); + + _ = hub.Raise(NoticeProbes.Started(0), Clock); + _ = hub.Raise(NoticeProbes.Started(1), Clock); + ValueTask stopping = hub.StopAsync(TimeSpan.MaxValue); + recorder.Hold.SetResult(); + await stopping; + + Assert.Equal([0, 1], recorder.Seen.Select(notice => notice.Scope.TurnIndex ?? -1)); + } + + [Fact(Timeout = 10_000)] + public async Task ANegativeStopTimeoutGivesUpAtOnce() + { + RecordingLoggerFactory logs = new(); + Recorder recorder = new() { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + NoticeHub hub = new(HookTable.Build([recorder]), logs.CreateLogger("hooks"), TimeProvider.System); + + _ = hub.Raise(NoticeProbes.Started(0), Clock); + await recorder.Entered.Task; + await hub.StopAsync(TimeSpan.FromSeconds(-5)); + recorder.Hold.SetResult(); + + Assert.Equal(1, Assert.Single(logs.Of(205)).Field("Mailboxes")); + } + + [Fact] + public void AConversationIsReloadedWhenItsMailboxHoldsAnEarlierSession() + { + NoticeHub hub = new(HookTable.Build([new Recorder()]), NullLogger.Instance, TimeProvider.System); + Guid first = Guid.CreateVersion7(); + Guid second = Guid.CreateVersion7(); + + _ = hub.Raise(NoticeProbes.Started(0, first), new FakeTimeProvider(DateTimeOffset.UnixEpoch)); + + Assert.False(hub.LoadedBefore("c1", first)); + Assert.True(hub.LoadedBefore("c1", second)); + Assert.False(hub.LoadedBefore("c2", second)); + } + + /// Hears turn starts only, so no hook wants . + private sealed class TurnsOnly : AgentHook + { + public ConcurrentQueue Turns { get; } = new(); + + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { + Turns.Enqueue(notice.Scope.TurnIndex ?? -1); + return ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs new file mode 100644 index 00000000..3a132d4f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs @@ -0,0 +1,178 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class NoticeHubTests + { + private static readonly FakeTimeProvider Clock = new(DateTimeOffset.UnixEpoch); + + private static NoticeHub Hub(params AgentHook[] hooks) => new(HookTable.Build(hooks), NullLogger.Instance, TimeProvider.System); + + // Raising only enqueues, so a hook that never returns cannot hold the raiser. + [Fact(Timeout = 10_000)] + public async Task ARaiseReturnsWhileTheHookIsStillBlocked() + { + Recorder blocked = new() { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + NoticeHub hub = Hub(blocked); + + _ = hub.Raise(NoticeProbes.Started(0), Clock); + await blocked.Entered.Task; + for (int turn = 1; turn <= 3; turn++) + { + _ = hub.Raise(NoticeProbes.Started(turn), Clock); + } + + Assert.Single(blocked.Seen); + blocked.Hold.SetResult(); + await hub.FlushAsync("c1"); + Assert.Equal([0, 1, 2, 3], blocked.Seen.Select(notice => notice.Scope.TurnIndex ?? -1)); + } + + // Three threads raise into one conversation; Sequence order is delivery order, with no gap. + [Fact(Timeout = 60_000)] + public async Task SequenceOrderIsDeliveryOrderUnderThreeRaisingThreads() + { + const int PerThread = 20_000; + Recorder recorder = new(timeout: null); + NoticeHub hub = Hub(recorder); + + Thread[] raisers = [.. Enumerable.Range(0, 3).Select(_ => new Thread(RaiseMany))]; + foreach (Thread raiser in raisers) + { + raiser.Start(); + } + + foreach (Thread raiser in raisers) + { + raiser.Join(); + } + + void RaiseMany() + { + for (int index = 0; index < PerThread; index++) + { + _ = hub.Raise(NoticeProbes.Started(index), Clock); + } + } + + await hub.FlushAsync("c1"); + long[] sequences = [.. recorder.Seen.Select(notice => notice.Scope.Sequence)]; + + Assert.Equal(Enumerable.Range(1, 3 * PerThread).Select(value => (long)value), sequences); + Assert.DoesNotContain(recorder.Threads, id => raisers.Any(raiser => raiser.ManagedThreadId == id)); + } + + [Fact(Timeout = 10_000)] + public async Task ASlowHookDoesNotDelayAnotherHook() + { + Recorder slow = new() { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + Recorder fast = new(); + NoticeHub hub = Hub(slow, fast); + + _ = hub.Raise(NoticeProbes.Started(0), Clock); + _ = hub.Raise(NoticeProbes.Started(1), Clock); + + await NoticeProbes.WaitUntilAsync(() => fast.Seen.Count == 2); + await slow.Entered.Task; + Assert.Single(slow.Seen); + slow.Hold.SetResult(); + } + + // A hook that throws on a HookFailed fault raises no fault about it. + [Fact(Timeout = 10_000)] + public async Task TwoHooksThatThrowOnEverythingDoNotRaiseFaultsAboutFaults() + { + Recorder recorder = new(); + NoticeHub hub = Hub(new Thrower(), new Thrower(), recorder); + + _ = hub.Raise(NoticeProbes.Started(), Clock); + await hub.FlushAsync("c1"); + await hub.FlushAsync("c1"); + + Assert.Single(recorder.Seen.OfType()); + Assert.Equal(2, recorder.Seen.OfType().Count(fault => fault.Kind == FaultKind.HookFailed)); + } + + [Fact] + public void ANoticeNoHookWantsStillGetsAnIdentity() + { + NoticeHub hub = Hub(new Recorder()); + + Guid eventId = hub.Raise(new StageChanged(NoticeProbes.Scope(), "a", "b"), Clock); + + Assert.NotEqual(Guid.Empty, eventId); + Assert.Equal(0, hub.MailboxCount); + } + + // HookNotice.EventId: a version 7 Guid per notice, and the one Raise returns is the one the hook is handed. + [Fact(Timeout = 10_000)] + public async Task EveryDeliveredNoticeCarriesTheVersionSevenIdItsRaiseReturned() + { + Recorder recorder = new(); + NoticeHub hub = Hub(recorder); + + Guid first = hub.Raise(NoticeProbes.Started(0), Clock); + Guid second = hub.Raise(NoticeProbes.Started(1), Clock); + await hub.FlushAsync("c1"); + + Assert.Equal([first, second], recorder.Seen.Select(notice => notice.EventId)); + Assert.All(recorder.Seen, notice => Assert.Equal(7, notice.EventId.Version)); + Assert.NotEqual(first, second); + } + + // The reader outlives the turn that started it, so it must not carry that turn's trace or async state. + [Fact(Timeout = 10_000)] + public async Task AHookRunsOutsideTheContextOfTheTurnThatFirstRaised() + { + ContextProbe probe = new(); + NoticeHub hub = Hub(probe); + + using (Activity turn = new Activity("turn").Start()) + { + probe.Flowing.Value = "turn 0"; + _ = hub.Raise(NoticeProbes.Started(0), Clock); + _ = hub.Raise(NoticeProbes.Started(1), Clock); + } + + await hub.FlushAsync("c1"); + + Assert.Equal([(null, null), (null, null)], probe.Seen); + } + + [Fact(Timeout = 10_000)] + public async Task HostNoticesHaveTheirOwnMailbox() + { + Recorder recorder = new(); + NoticeHub hub = Hub(recorder); + + _ = hub.Raise(new HostStarted(NoticeProbes.Scope(conversationId: null, turn: null), ["main"], 3), Clock); + _ = hub.Raise(NoticeProbes.Started(), Clock); + await hub.FlushAsync(conversationId: null); + await hub.FlushAsync("c1"); + + Assert.Equal(1, recorder.Seen.OfType().Single().Scope.Sequence); + Assert.Equal(1, recorder.Seen.OfType().Single().Scope.Sequence); + } + + /// Records the ambient activity and async-local value each delivery runs under. + private sealed class ContextProbe : AgentHook + { + public AsyncLocal Flowing { get; } = new(); + + public ConcurrentQueue<(string? Activity, string? Flowing)> Seen { get; } = new(); + + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { + Seen.Enqueue((Activity.Current?.OperationName, Flowing.Value)); + return ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs new file mode 100644 index 00000000..72016f68 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs @@ -0,0 +1,151 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class NoticeHubTimeoutTests + { + // With a timeout set, the delivery is abandoned, logged once with hook and notice type, + // and Fault(HookFailed) goes to the other hooks only. + [Fact(Timeout = 10_000)] + public async Task ATimedOutDeliveryIsAbandonedAndTheOtherHooksHearOfIt() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingLoggerFactory logs = new(); + Recorder stuck = new(timeout: TimeSpan.FromSeconds(1)) { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + Recorder other = new(); + NoticeHub hub = new(HookTable.Build([stuck, other]), logs.CreateLogger("hooks"), time); + + _ = hub.Raise(NoticeProbes.Started(0), time); + await stuck.Entered.Task; + await time.WaitForTimersAsync(time.GetUtcNow() + TimeSpan.FromSeconds(1), 2); + time.Advance(TimeSpan.FromSeconds(1)); + _ = hub.Raise(NoticeProbes.Started(1), time); + await NoticeProbes.WaitUntilAsync(() => stuck.Seen.Count == 2); + stuck.Hold.SetResult(); + await hub.FlushAsync("c1"); + + Fault fault = Assert.Single(other.Seen.OfType()); + Assert.Equal(FaultKind.HookFailed, fault.Kind); + Assert.DoesNotContain(stuck.Seen, notice => notice is Fault); + CapturedLine line = Assert.Single(logs.Of(203)); + Assert.EndsWith(nameof(Recorder), line.Field("HookType"), StringComparison.Ordinal); + Assert.Equal(nameof(TurnStarted), line.Field("NoticeType")); + } + + // With null, AgentCore keeps waiting, logs every 30 s, raises the fault once, and drops nothing. + [Fact(Timeout = 10_000)] + public async Task ANullTimeoutWaitsLogsEveryThirtySecondsAndDropsNothing() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingLoggerFactory logs = new(); + Recorder patient = new(timeout: null) { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + Recorder other = new(); + NoticeHub hub = new(HookTable.Build([patient, other]), logs.CreateLogger("hooks"), time); + + _ = hub.Raise(NoticeProbes.Started(0), time); + _ = hub.Raise(NoticeProbes.Started(1), time); + await patient.Entered.Task; + for (int wait = 1; wait <= 2; wait++) + { + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.StillWaitingEvery, 1); + time.Advance(NoticeHub.StillWaitingEvery); + } + + // The still-waiting line is written on the timer's continuation, which released work could outrun. + await NoticeProbes.WaitUntilAsync(() => logs.Of(204).Count == 2); + patient.Hold.SetResult(); + await hub.FlushAsync("c1"); + + Assert.Equal([0, 1], patient.Seen.OfType().Select(notice => notice.Scope.TurnIndex ?? -1)); + Assert.Equal(2, logs.Of(204).Count); + _ = Assert.Single(other.Seen.OfType()); + } + + public static TheoryData LongerThanATimerCanWait => [TimeSpan.MaxValue, TimeSpan.FromDays(60), Timeout.InfiniteTimeSpan]; + + // A timer refuses these, so they mean what null means: keep waiting, log every 30 s, drop nothing. + [Theory(Timeout = 10_000)] + [MemberData(nameof(LongerThanATimerCanWait))] + public async Task ATimeoutLongerThanATimerCanWaitMeansKeepWaiting(TimeSpan timeout) + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingLoggerFactory logs = new(); + Recorder patient = new(timeout) { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + NoticeHub hub = new(HookTable.Build([patient]), logs.CreateLogger("hooks"), time); + + _ = hub.Raise(NoticeProbes.Started(0), time); + _ = hub.Raise(NoticeProbes.Started(1), time); + await patient.Entered.Task; + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.StillWaitingEvery, 1); + time.Advance(NoticeHub.StillWaitingEvery); + + // The still-waiting line is written on the timer's continuation, which released work could outrun. + await NoticeProbes.WaitUntilAsync(() => logs.Of(204).Count == 1); + patient.Hold.SetResult(); + await hub.FlushAsync("c1"); + + Assert.Equal([0, 1], patient.Seen.Select(notice => notice.Scope.TurnIndex ?? -1)); + _ = Assert.Single(logs.Of(204)); + Assert.Empty(logs.Of(203)); + } + + // AgentHook.NoticeTimeout's default is 30 s; a negative timeout is a mistake and gets it. + [Fact(Timeout = 10_000)] + public async Task ANegativeTimeoutFallsBackToThirtySeconds() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingLoggerFactory logs = new(); + Recorder stuck = new(TimeSpan.FromSeconds(-5)) { Hold = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + NoticeHub hub = new(HookTable.Build([stuck]), logs.CreateLogger("hooks"), time); + + _ = hub.Raise(NoticeProbes.Started(0), time); + await stuck.Entered.Task; + await time.WaitForTimersAsync(time.GetUtcNow() + TimeSpan.FromSeconds(30), 2); + time.Advance(TimeSpan.FromSeconds(30)); + await hub.FlushAsync("c1"); + stuck.Hold.SetResult(); + + Assert.Equal(30_000L, Assert.Single(logs.Of(203)).Field("TimeoutMs")); + } + + // An engine failure (here, the log sink throwing) must not end the hook's reader: its later notices still go. + [Fact(Timeout = 10_000)] + public async Task AReaderOutlivesAFailureOfTheEngineItself() + { + BrokenSink sink = new(); + NoticeHub hub = new(HookTable.Build([new Thrower()]), sink, TimeProvider.System); + + _ = hub.Raise(NoticeProbes.Started(0), TimeProvider.System); + _ = hub.Raise(NoticeProbes.Started(1), TimeProvider.System); + await hub.FlushAsync("c1"); + + Assert.Equal([206, 206], sink.Written); + } + + /// A logger that throws on the "hook failed" line, the way a broken sink would. + private sealed class BrokenSink : ILogger + { + public ConcurrentQueue Written { get; } = new(); + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (eventId.Id == 202) + { + throw new InvalidOperationException("the log sink is down"); + } + + Written.Enqueue(eventId.Id); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeOrderTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeOrderTests.cs new file mode 100644 index 00000000..68d781ea --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeOrderTests.cs @@ -0,0 +1,97 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class NoticeOrderTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // One conversation's notices come from the turn thread, the store's write queue (the transcript + // write faults), two voice-like threads, and a reloaded session. (SessionId, Sequence) is gap-free and causal. + [Fact(Timeout = 60_000)] + public async Task NoticesFromEveryThreadAndAReloadAreGapFreeAndCausal() + { + RecordingHook hook = new(); + using RequestRecordingChatClient reply = new("one", "two", "three", "four"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply, [hook], new ThrowingConversationStore())["main"]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + + ConversationSession first = factory.Create("c1"); + Task firstTurn = first.RunTurnAsync("turn 0", Ct); + + // The voice threads start once the session opened, so the session's start is still its first notice. + // The user's and the agent's states change on their own threads until the session's last turn ends, + // so their raises overlap each other, the turns and the write queue. + _ = await hook.WaitForAsync(); + using CancellationTokenSource turnsDone = new(); + int[] changes = new int[2]; + Thread[] voices = [.. new[] { VoiceParty.User, VoiceParty.Agent }.Select(party => new Thread(() => + { + while (!turnsDone.IsCancellationRequested) + { + bool listening = changes[(int)party] % 2 == 0; + _ = first.Hooks.Raise(new VoiceStateChanged( + first.Hooks.Scope(turnIndex: null, stage: null), + party, + listening ? VoiceState.Listening : VoiceState.Speaking, + listening ? VoiceState.Speaking : VoiceState.Listening)); + changes[(int)party]++; + } + }))]; + Array.ForEach(voices, static voice => voice.Start()); + _ = await firstTurn; + for (int turn = 1; turn < 3; turn++) + { + _ = await first.RunTurnAsync($"turn {turn}", Ct); + } + + await turnsDone.CancelAsync(); + Array.ForEach(voices, static voice => voice.Join()); + await first.FlushTranscriptAsync(); + await first.Lifetime.DisposeAsync(UnloadCause.Idle); + + ConversationSession second = factory.Create("c1"); + _ = await second.RunTurnAsync("after the reload", Ct); + await second.FlushTranscriptAsync(); + await second.FlushNoticesAsync(); + + IReadOnlyList notices = hook.Notices; + + // Gap-free: the mailbox numbers every notice of the conversation from 1, across both sessions. + Assert.Equal(Enumerable.Range(1, notices.Count).Select(value => (long)value), notices.Select(notice => notice.Scope.Sequence)); + + // SessionId sorts by load time, so it never goes backwards in delivery order. + Assert.All(notices.Zip(notices.Skip(1)), pair => Assert.True(pair.First.Scope.SessionId.CompareTo(pair.Second.Scope.SessionId) <= 0)); + Assert.Equal(2, notices.Select(notice => notice.Scope.SessionId).Distinct().Count()); + + // Causal: each session starts first and the first session's unload is its last notice. + Assert.All( + notices.GroupBy(notice => notice.Scope.SessionId), + session => Assert.IsType(session.First())); + Assert.Equal([ConversationOrigin.New, ConversationOrigin.Reloaded], hook.Of().Select(started => started.Origin)); + ConversationUnloaded unloaded = Assert.Single(hook.Of()); + Assert.Equal(UnloadCause.Idle, unloaded.Cause); + Assert.Equal(hook.Of()[0].Scope.SessionId, unloaded.Scope.SessionId); + Assert.Same(unloaded, notices.Last(notice => notice.Scope.SessionId == unloaded.Scope.SessionId)); + + // Each turn starts before it completes, and before the write queue reports its failed store write. + Assert.Equal(4, hook.Of().Count); + foreach (HookNotice after in hook.Of().Concat(hook.Of().Where(fault => fault.Kind == FaultKind.TranscriptWriteFailed))) + { + TurnStarted started = hook.Of().Single(s => s.Scope.SessionId == after.Scope.SessionId && s.Scope.TurnIndex == after.Scope.TurnIndex); + Assert.True(started.Scope.Sequence < after.Scope.Sequence, $"{after} came before its turn started"); + } + + Assert.Equal(changes.Sum(), hook.Of().Count); + Assert.Contains(hook.Of(), fault => fault.Kind == FaultKind.TranscriptWriteFailed); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs new file mode 100644 index 00000000..b6e9ee19 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs @@ -0,0 +1,44 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.TestSupport; + +namespace AgentCore.Application.Tests.Hooks +{ + /// Scopes, notices and a wait the engine tests share. + internal static class NoticeProbes + { + internal static HookScope Scope(string? conversationId = "c1", int? turn = 0, Guid? session = null) => + new(conversationId, "main", turn, "", session ?? Guid.Empty, 0, DateTimeOffset.UnixEpoch); + + internal static TurnStarted Started(int turn = 0, Guid? session = null, string conversationId = "c1") => + new(Scope(conversationId, turn, session), "only", "hi"); + + /// + /// Waits for a state no event announces, such as a fast hook's second delivery. It yields between checks + /// and gives up after 5 s; it never waits on a clock. + /// + [AssertionMethod] + internal static async Task WaitUntilAsync(Func condition) + { + using CancellationTokenSource limit = new(TimeSpan.FromSeconds(5)); + while (!condition()) + { + await Task.Yield(); + limit.Token.ThrowIfCancellationRequested(); + } + } + } + + /// Throws on every notice it overrides, the fault notice included. + internal sealed class Thrower : AgentHook + { + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => + throw new InvalidOperationException("thrower"); + + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => + throw new InvalidOperationException("thrower on end"); + + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => + throw new InvalidOperationException("thrower on fault"); + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/Recorder.cs b/tests/AgentCore.Application.Tests/Hooks/Recorder.cs new file mode 100644 index 00000000..ea902917 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/Recorder.cs @@ -0,0 +1,70 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.Application.Tests.Hooks +{ + /// Records every notice it is handed, in delivery order; may hold each delivery until released. + internal sealed class Recorder(TimeSpan? timeout = null) : AgentHook + { + private readonly Lock _gate = new(); + private readonly List _seen = []; + private readonly List _threads = []; + + public TaskCompletionSource? Hold { get; set; } + + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public IReadOnlyList Seen + { + get + { + lock (_gate) + { + return [.. _seen]; + } + } + } + + public IReadOnlyList Threads + { + get + { + lock (_gate) + { + return [.. _threads]; + } + } + } + + public override TimeSpan? NoticeTimeout => timeout; + + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => Record(notice); + + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => Record(notice); + + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => Record(notice); + + public override ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => Record(notice); + + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => Record(notice); + + public override ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => Record(notice); + + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => Record(notice); + + private async ValueTask Record(HookNotice notice) + { + lock (_gate) + { + _seen.Add(notice); + _threads.Add(Environment.CurrentManagedThreadId); + } + + _ = Entered.TrySetResult(); + if (Hold is { } hold) + { + await hold.Task; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/RequestsSeen.cs b/tests/AgentCore.Application.Tests/Hooks/RequestsSeen.cs new file mode 100644 index 00000000..6cd1d831 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/RequestsSeen.cs @@ -0,0 +1,51 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Llm; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Hooks +{ + /// Records what each streamed model request carried, in call order. + internal sealed class RequestsSeen(IChatClient inner) : DelegatingChatClient(inner) + { + public List Requests { get; } = []; + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List sent = [.. messages]; + Record(sent, options); + + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(sent, options, cancellationToken)) + { + yield return update; + } + } + + public override Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List sent = [.. messages]; + Record(sent, options); + return base.GetResponseAsync(sent, options, cancellationToken); + } + + private void Record(List sent, ChatOptions? options) + { + lock (Requests) + { + Requests.Add(new Request( + options?.Instructions ?? string.Empty, + [.. sent.Select(static message => message.Text)], + [.. options?.Tools?.Select(static tool => tool.Name) ?? []], + options?.AdditionalProperties?.TryGetValue(ChatRequestProperties.ConversationId, out string? stamped) == true ? stamped : null)); + } + } + + /// What one model request carried. + /// The instructions, or empty. + /// The text of each message. + /// The name of each tool offered. + /// The conversation id stamped for the vendor, or . + internal sealed record Request(string Instructions, IReadOnlyList Messages, IReadOnlyList Tools, string? Stamp); + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs new file mode 100644 index 00000000..9a398ebe --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs @@ -0,0 +1,306 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class RunEndGateTests + { + private const string ContinueText = "CONTINUE-WITH-THIS"; + + // Document: loop: re-runs until the todo list is empty, up to maxRounds. + private const string TodoLoopYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: coder, instructions: "fix bugs", todos: true, loop: { maxRounds: 3, until: [{ todos: {} }] } } + entries: + main: + agent: coder + """; + + // The same loop with a cap of 4, so a run count of 4 can only come from maxRounds, never from the default 3. + private const string FourRoundTodoLoopYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: coder, instructions: "fix bugs", todos: true, loop: { maxRounds: 4, until: [{ todos: {} }] } } + entries: + main: + agent: coder + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // One user row, every run's assistant row, the continuation never stored, one TurnCompleted. + [Fact] + public async Task AContinueRunsAgainBelowTheSealAndStoresOneUserRow() + { + RecordingConversationStore store = new(); + RequestRecordingChatClient model = Answers(); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, model, [new Gate(gate => { if (gate.Iteration == 1) { gate.Continue(ContinueText); } }), notices], store); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + Assert.Equal(2, model.Requests.Count); + Assert.Equal($"user:{ContinueText}", model.Requests[1][^1]); + IReadOnlyList rows = store.Live(session.ConversationId); + Assert.Equal("go", Assert.Single(rows, row => row.Content.Role == ChatRole.User).Content.Text); + Assert.DoesNotContain(rows, row => row.Content.Text.Contains(ContinueText, StringComparison.Ordinal)); + Assert.Equal(2, rows.Count(row => row.Content.Role == ChatRole.Assistant)); + Assert.Equal("answer 2", turn.ReplyText); + _ = Assert.Single(notices.Of()); + } + + // A re-run reads the earlier run's reply again; the framework's tag on what it reads must not reach the stored row. + [Fact] + public async Task AContinuedRunStoresTheRepliesWithoutTheFrameworksReadTag() + { + RecordingConversationStore store = new(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, + new VendorPropertyChatClient(Answers()), + [new Gate(gate => { if (gate.Iteration == 1) { gate.Continue(ContinueText); } })], + store); + + _ = await session.RunTurnAsync("go", Ct); + await session.FlushTranscriptAsync(); + + IReadOnlyList replies = [.. store.Live(session.ConversationId).Where(row => row.Content.Role == ChatRole.Assistant)]; + Assert.Equal(2, replies.Count); + Assert.All(replies, reply => Assert.Equal(["vendor.trace"], reply.Content.AdditionalProperties?.Keys ?? [])); + } + + // A re-run reads the conversation, this turn's user message and the + // earlier run's reply, then the continuation. Nothing of it is stored (the test above). + [Fact] + public async Task AContinuedRunSeesTheTurnItContinues() + { + RequestRecordingChatClient model = Answers(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, model, [new Gate(gate => { if (gate.Scope.TurnIndex == 1 && gate.Iteration == 1) { gate.Continue(ContinueText); } })]); + + _ = await session.RunTurnAsync("first", Ct); + _ = await session.RunTurnAsync("go", Ct); + + Assert.Equal(["user:first", "assistant:answer 1", "user:go", "assistant:answer 2", $"user:{ContinueText}"], model.Requests[2]); + } + + // The first run of a turn reads the conversation and the caller's message once, with or without the gate. + [Fact] + public async Task ATurnThatRunsOnceReadsTheConversationOnce() + { + RequestRecordingChatClient model = Answers(); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [new Gate(_ => { })]); + + _ = await session.RunTurnAsync("first", Ct); + _ = await session.RunTurnAsync("go", Ct); + + Assert.Equal(2, model.Requests.Count); + Assert.Equal(["user:first", "assistant:answer 1", "user:go"], model.Requests[1]); + } + + // The same for loop:: the second run reads this turn's user message and the first run's work, then the feedback. + [Fact] + public async Task ADocumentLoopsSecondRunSeesTheTurnItContinues() + { + RequestCapturingChatClient capture = new(TodoAddingModel()); + ConversationSession session = HookSessions.Create(TodoLoopYaml, capture); + + _ = await session.RunTurnAsync("fix it", Ct); + + IReadOnlyList secondRun = capture.Requests[2]; + Assert.Equal( + ["user:fix it", "assistant:FunctionCallContent", "tool:FunctionResultContent", "assistant:added"], + secondRun.Take(4).Select(message => $"{message.Role}:{(message.Text.Length > 0 ? message.Text : message.Contents[0].GetType().Name)}")); + Assert.StartsWith("You still have incomplete todo items.", secondRun[4].Text, StringComparison.Ordinal); + } + + // ExcludeOnBehalfOfMessages keeps the framework's tag off the stored row. + [Fact] + public async Task TheContinuationNeverReachesTheCallersStream() + { + RequestRecordingChatClient model = Answers(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, model, [new Gate(gate => { if (gate.Iteration == 1) { gate.Continue(ContinueText); } })]); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("go", Ct)) + { + updates.Add(update); + } + + Assert.Equal(2, model.Requests.Count); + Assert.DoesNotContain(updates, update => update.Text.Contains(ContinueText, StringComparison.Ordinal)); + Assert.DoesNotContain(updates, update => update.Role == ChatRole.User); + } + + // With no loop:, the cap is 3 runs. + [Fact] + public async Task ContinueIsCappedAtThreeRunsWithoutALoopBlock() + { + RequestRecordingChatClient model = Answers(); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [new Gate(gate => gate.Continue("again"))]); + + _ = await session.RunTurnAsync("go", Ct); + + Assert.Equal(3, model.Requests.Count); + } + + // The same tag leak applies to loop: itself, and the same fix covers it. + [Fact] + public async Task ADocumentLoopNoLongerLeaksItsFeedbackToTheStream() + { + ToolCallingChatClient model = TodoAddingModel(); + ConversationSession session = HookSessions.Create(TodoLoopYaml, model); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("fix it", Ct)) + { + updates.Add(update); + } + + Assert.Equal(6, model.Calls); + Assert.DoesNotContain(updates, update => update.Role == ChatRole.User); + } + + // The document's until: decides first. An open todo re-runs the loop with the todo feedback + // every time, up to maxRounds (3 runs x 2 rounds), so the hook, which goes last, never continues. + [Fact] + public async Task TheDocumentsUntilDecidesFirst() + { + ToolCallingChatClient model = TodoAddingModel(); + RequestCapturingChatClient capture = new(model); + ConversationSession session = HookSessions.Create(TodoLoopYaml, capture, [new Gate(gate => gate.Continue(ContinueText))]); + + _ = await session.RunTurnAsync("fix it", Ct); + + Assert.Equal(6, model.Calls); + Assert.DoesNotContain(capture.Requests, request => request.Any(message => message.Text == ContinueText)); + } + + // With no open todo the document's until: stops, and the hook's Continue runs again up to maxRounds (4), not 3. + [Fact] + public async Task AHookContinuesAfterTheDocumentLoopStopsUpToMaxRounds() + { + RequestRecordingChatClient model = Answers(); + ConversationSession session = HookSessions.Create(FourRoundTodoLoopYaml, model, [new Gate(gate => gate.Continue(ContinueText))]); + + _ = await session.RunTurnAsync("go", Ct); + + Assert.Equal(4, model.Requests.Count); + Assert.All(model.Requests.Skip(1), request => Assert.Contains($"user:{ContinueText}", request)); + } + + // The run-end gate fires for the turn's own run, never for an agent-as-tool child. + [Fact] + public async Task AnAgentToolChildNeverFiresTheGate() + { + List seen = []; + ToolCallingChatClient model = new("done", new Dictionary(StringComparer.Ordinal) { ["query"] = "help me" }); + ConversationSession session = HookSessions.Create(HookSessions.DelegatingYaml, model, [new Gate(gate => seen.Add(gate.AgentId))]); + + _ = await session.RunTurnAsync("go", Ct); + + Assert.Equal(["ask_helper"], model.Called); + Assert.Equal(["only"], seen); + } + + // A Closed run-end hook that fails stops the chain, and the turn does not run again. + [Fact] + public async Task AClosedHookThatFailsEndsTheTurnWithoutRunningAgain() + { + RequestRecordingChatClient model = Answers(); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, model, [new ClosedThrowingGate(), new Gate(gate => gate.Continue(ContinueText)), notices]); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + await session.FlushNoticesAsync(); + + _ = Assert.Single(model.Requests); + Assert.Equal("answer 1", turn.ReplyText); + Assert.Equal(FaultKind.HookFailed, Assert.Single(notices.Of()).Kind); + } + + // LoopAgent hands every run the same options, so no layer may stack across runs. + [Fact] + public async Task AContinuedRunIsOneToolNoticePerCallAndOneModelNoticePerRound() + { + RecordingHook notices = new(); + ToolCallingChatClient model = new("final", everyRun: true); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [new Gate(gate => { if (gate.Iteration == 1) { gate.Continue(ContinueText); } }), notices], + tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushNoticesAsync(); + + // Each of the two runs is a tool round and an answer round. + Assert.Equal(ContinueText, model.Prompts[^1]); + Assert.Equal(["price_lookup", "price_lookup"], model.Called); + Assert.Equal(["price_lookup", "price_lookup"], notices.Of().Select(called => called.ToolName)); + Assert.Equal(4, notices.Of().Count); + } + + private static RequestRecordingChatClient Answers() => new("answer 1", "answer 2", "answer 3", "answer 4"); + + // Adds a todo on every run, so the todo list never empties and until: todos always asks for another run. + private static ToolCallingChatClient TodoAddingModel() => new( + "added", + new Dictionary(StringComparer.Ordinal) + { + ["todos"] = new object[] { new Dictionary(StringComparer.Ordinal) { ["title"] = "T" } }, + }, + everyRun: true); + + /// Stamps a vendor property on every update, so each reply message carries a property bag of its own. + private sealed class VendorPropertyChatClient(IChatClient inner) : DelegatingChatClient(inner) + { + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + update.AdditionalProperties = new() { ["vendor.trace"] = "t1" }; + yield return update; + } + } + } + + private sealed class Gate(Action decide) : AgentHook + { + public override ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) + { + decide(gate); + return default; + } + } + + private sealed class ClosedThrowingGate : AgentHook + { + public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + + public override ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) => + throw new InvalidOperationException("broken"); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs new file mode 100644 index 00000000..4dfdadcc --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs @@ -0,0 +1,172 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Blobs; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Knowledge; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.Application.Tools.Builtin; +using AgentCore.Domain.Knowledge; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class RunFactNoticeTests : IDisposable + { + private const string KnowledgeYaml = """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + knowledge: + kind: qdrant + collection: kb + fields: { body: text } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok", knowledge: { mode: prefetch, scoped: false } } + entries: + main: + agent: only + """; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "agentcore-hook-publish-" + Guid.NewGuid().ToString("N")); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task APrefetchSearchIsNamedWithItsHits() + { + RecordingHook hook = new(); + StubKnowledgePort port = new([ + new KnowledgeCard { CardId = "a", Text = "belt tension", ViaLink = false }, + new KnowledgeCard { CardId = "b", Text = "belt slip", ViaLink = false }, + ]); + ConversationSession session = CreateWithKnowledge(hook, port); + + _ = await session.RunTurnAsync("my belt slips", Ct); + await session.FlushNoticesAsync(); + + KnowledgeSearched searched = Assert.Single(hook.Of()); + Assert.Equal(2, searched.Hits); + Assert.Null(searched.Failure); + Assert.Equal(0, searched.Scope.TurnIndex); + } + + [Fact] + public async Task AFailedSearchIsNamedWithItsFailure() + { + RecordingHook hook = new(); + ConversationSession session = CreateWithKnowledge(hook, new ThrowingKnowledgePort(new InvalidOperationException("kb down"))); + + _ = await session.RunTurnAsync("my belt slips", Ct); + await session.FlushNoticesAsync(); + + KnowledgeSearched searched = Assert.Single(hook.Of()); + Assert.Equal(0, searched.Hits); + Assert.Contains("kb down", searched.Failure, StringComparison.Ordinal); + } + + // One search, one notice: a search the store answered that fails afterwards is named once, as failed. + [Fact] + public async Task ASearchThatFailsAfterTheStoreAnsweredIsNamedOnceAsFailed() + { + RecordingHook hook = new(); + StubKnowledgePort port = new([new KnowledgeCard { CardId = "a", Text = "belt tension", ViaLink = false }]); + ConversationSession session = CreateWithKnowledge( + hook, port, KnowledgeYaml.Replace("scoped: false", "scoped: false, citations: true", StringComparison.Ordinal), new ThrowingCitations()); + + _ = await session.RunTurnAsync("my belt slips", Ct); + await session.FlushNoticesAsync(); + + KnowledgeSearched searched = Assert.Single(hook.Of()); + Assert.Contains("no label", searched.Failure, StringComparison.Ordinal); + } + + // An agent-as-tool run begins and ends inside the parent's turn. + [Fact] + public async Task AnAgentToolRunIsNamedWhenItStartsAndEnds() + { + RecordingHook hook = new(); + ToolCallingChatClient model = new("done", new Dictionary(StringComparer.Ordinal) { ["query"] = "help me" }); + ConversationSession session = HookSessions.Create(HookSessions.DelegatingYaml, model, [hook]); + + _ = await session.RunTurnAsync("go", Ct); + await session.FlushNoticesAsync(); + + SubagentStarted started = Assert.Single(hook.Of()); + SubagentEnded ended = Assert.Single(hook.Of()); + Assert.Equal("helper", started.AgentId); + Assert.NotNull(started.ParentToolCallId); + Assert.Equal(started.ParentToolCallId, ended.ParentToolCallId); + Assert.Equal(SubagentOutcome.Answered, ended.Outcome); + Assert.True(started.Scope.Sequence < ended.Scope.Sequence); + } + + // The publish fixture copies FilePublishToolDefinitionTests.cs:23-28,188-208. + [Fact] + public async Task APublishedFileIsNamedWithItsBlobKey() + { + RecordingHook hook = new(); + HookRuntime runtime = HookRuntime.Create([hook], loggers: null); + SessionHooks hooks = new(runtime, "conversation-1", "main", TimeProvider.System); + _ = Directory.CreateDirectory(Path.Combine(_root, "conversation-1")); + File.WriteAllText(Path.Combine(_root, "conversation-1", "rows.csv"), "a,b\n1,2\n"); + ToolConfiguration declared = new() { Id = "publish", Kind = ToolKind.Builtin, Uses = BuiltinToolNames.FilePublish, Description = "Publish a file." }; + AIFunction function = (AIFunction)new FilePublishToolDefinition().Build( + declared, new BuiltinToolPorts(ChatClients: null, Blobs: new RecordingBlobStore(), WorkspaceRoot: _root)); + AIFunctionArguments arguments = new(new Dictionary(StringComparer.Ordinal) { ["path"] = "rows.csv" }); + _ = new TurnInvocation + { + ConversationId = "conversation-1", + TurnIndex = 0, + Stage = string.Empty, + Workspace = Path.Combine(_root, "conversation-1"), + Hooks = hooks, + }.FileIn(arguments); + + _ = await function.InvokeAsync(arguments, Ct); + await hooks.FlushAsync(); + + FilePublished published = Assert.Single(hook.Of()); + Assert.Equal(("rows.csv", 8L, "text/csv", "conversation-1/rows.csv"), (published.Name, published.Length, published.MediaType, published.BlobKey)); + } + + private static ConversationSession CreateWithKnowledge( + RecordingHook hook, Ports.IKnowledgeRetrievalPort port, string yaml = KnowledgeYaml, IKnowledgeCitationFormatter? citations = null) + { + ScriptedChatClient reply = new("an answer"); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), + new AgentCompilationContext(new FakeChatClientFactory(reply)) { Knowledge = port, Hooks = [hook], Citations = citations })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(); + } + + private sealed class ThrowingCitations : IKnowledgeCitationFormatter + { + public string Name => "throwing"; + + public string? Format(KnowledgeCard card) => throw new InvalidOperationException("no label"); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs new file mode 100644 index 00000000..27a64bc0 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs @@ -0,0 +1,208 @@ +using System.Collections.Concurrent; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class RunGateTests + { + private const string TodosLoopYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: coder, instructions: "fix bugs", todos: true, loop: { maxRounds: 2, until: [{ todos: {} }] } } + entries: + main: + agent: coder + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Instructions a run gate adds are seen on every round of that run. + [Fact] + public async Task InstructionsAddedByAHookReachEveryRoundOfTheRun() + { + RequestsSeen model = new(new ToolCallingChatClient("final")); + Gate hook = new(gate => gate.AddInstructions("Speak like a pirate.")); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [hook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal(2, model.Requests.Count); + Assert.All(model.Requests, request => Assert.Contains("Speak like a pirate.", request.Instructions, StringComparison.Ordinal)); + } + + // The turn's own run is not nested; an agent-as-tool child is. + [Fact] + public async Task TheTurnsRunIsNotNestedAndAnAgentToolChildIs() + { + List<(string AgentId, bool Nested)> seen = []; + Gate hook = new(gate => seen.Add((gate.AgentId, gate.Nested))); + ToolCallingChatClient model = new("done", new Dictionary(StringComparer.Ordinal) { ["query"] = "help me" }); + ConversationSession session = HookSessions.Create(HookSessions.DelegatingYaml, model, [hook]); + + _ = await session.RunTurnAsync("go", Ct); + + Assert.Equal([("only", false), ("helper", true)], seen); + } + + // A graph participant is a run of its own, so the gate fires for each one, nested, under the turn + // that runs the graph. Two turns: a graph row that keeps its workflow session must not replay the first. + // A concurrent row runs its participants at once, so the order within a turn is not asserted. + [Theory] + [InlineData(HookSessions.GraphYaml)] + [InlineData(HookSessions.KeptGraphYaml)] + [InlineData(HookSessions.ExplicitGraphYaml)] + [InlineData(HookSessions.ConcurrentGraphYaml)] + public async Task EachParticipantOfAGraphRowFiresTheGateNested(string yaml) + { + ConcurrentQueue<(string AgentId, bool Nested, int? Turn)> seen = []; + Gate hook = new(gate => seen.Enqueue((gate.AgentId, gate.Nested, gate.Scope.TurnIndex))); + ConversationSession session = HookSessions.Create(yaml, new ToolCallingChatClient("done"), [hook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("first", Ct); + _ = await session.RunTurnAsync("second", Ct); + + Assert.Equal( + [("researcher", true, 0), ("responder", true, 0), ("researcher", true, 1), ("responder", true, 1)], + seen.OrderBy(static fired => fired.Turn).ThenBy(static fired => fired.AgentId, StringComparer.Ordinal)); + } + + [Fact] + public async Task ATurnNoteAndARunNoteEachArriveOncePerRound() + { + RequestsSeen model = new(new ToolCallingChatClient("final")); + TurnNote turnHook = new(); + Gate runHook = new(gate => gate.AddMessages([new ChatMessage(ChatRole.System, "per-run note")])); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [turnHook, runHook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal(2, model.Requests.Count); + Assert.All(model.Requests, request => + { + Assert.Equal(1, request.Messages.Count(text => text == "per-turn note")); + Assert.Equal(1, request.Messages.Count(text => text == "per-run note")); + }); + } + + // Every pass of a loop is a run: the gate fires again and the tool it adds is offered once, never stacked. + [Fact] + public async Task AToolAddedByAHookIsOfferedOncePerRoundAcrossALoopsIterations() + { + RequestsSeen model = new(new ToolCallingChatClient( + "added", + new Dictionary(StringComparer.Ordinal) + { + ["todos"] = new object[] { new Dictionary(StringComparer.Ordinal) { ["title"] = "T" } }, + })); + int fired = 0; + Gate hook = new(gate => + { + _ = Interlocked.Increment(ref fired); + gate.AddTools([AIFunctionFactory.Create(() => "extra answer", "extra_tool")]); + }); + ConversationSession session = HookSessions.Create(TodosLoopYaml, model, [hook]); + + _ = await session.RunTurnAsync("fix it", Ct); + + Assert.Equal(2, fired); + Assert.All(model.Requests, request => Assert.Equal(1, request.Tools.Count(name => name == "extra_tool"))); + } + + // Fail-open : a hook that stages and then throws adds nothing; the next hook still does. + [Fact] + public async Task AHookThatThrowsAfterStagingAddsNothingAndTheNextHookStillDoes() + { + RequestsSeen model = new(new ScriptedChatClient("done")); + Gate failing = new(gate => StageAllThenThrow(gate)); + Gate healthy = new(gate => StageAll(gate, "healthy")); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [failing, healthy]); + + _ = await session.RunTurnAsync("hi", Ct); + + RequestsSeen.Request request = Assert.Single(model.Requests); + Assert.DoesNotContain("failing", request.Instructions, StringComparison.Ordinal); + Assert.DoesNotContain("failing note", request.Messages); + Assert.DoesNotContain("failing_tool", request.Tools); + Assert.Contains("healthy", request.Instructions, StringComparison.Ordinal); + Assert.Contains("healthy note", request.Messages); + Assert.Contains("healthy_tool", request.Tools); + } + + // The deadline is 2 s. A hook still running past it is abandoned and its staged additions dropped. + [Fact(Timeout = 10_000)] + public async Task AHookThatMissesTheDeadlineAddsNothingAndTheNextHookStillDoes() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RequestsSeen model = new(new ScriptedChatClient("done")); + TaskCompletionSource never = new(TaskCreationOptions.RunContinuationsAsynchronously); + SlowHook slow = new(never.Task); + Gate healthy = new(gate => StageAll(gate, "healthy")); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [slow, healthy], time: time); + + Task turn = session.RunTurnAsync("hi", Ct); + await slow.Started.Task.WaitAsync(Ct); + await time.WaitForTimersAsync(DateTimeOffset.UnixEpoch + GatePoint.BeforeRun.Deadline, 2); + time.Advance(GatePoint.BeforeRun.Deadline); + _ = await turn; + never.SetResult(); + + RequestsSeen.Request request = Assert.Single(model.Requests); + Assert.DoesNotContain("slow", request.Instructions, StringComparison.Ordinal); + Assert.Contains("healthy", request.Instructions, StringComparison.Ordinal); + Assert.Contains("healthy_tool", request.Tools); + } + + private static void StageAll(RunGate gate, string name) + { + gate.AddInstructions(name); + gate.AddMessages([new ChatMessage(ChatRole.System, $"{name} note")]); + gate.AddTools([AIFunctionFactory.Create(() => "answer", $"{name}_tool")]); + } + + private static void StageAllThenThrow(RunGate gate) + { + StageAll(gate, "failing"); + throw new InvalidOperationException("hook failed"); + } + + private sealed class SlowHook(Task hang) : AgentHook + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) + { + StageAll(gate, "slow"); + Started.SetResult(); + await hang; + } + } + + private sealed class Gate(Action decide) : AgentHook + { + public override ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) + { + decide(gate); + return default; + } + } + + private sealed class TurnNote : AgentHook + { + public override ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) + { + gate.AddContext("per-turn note"); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs new file mode 100644 index 00000000..aec8dcc3 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// The session faults that the store and the extractor cause. + public sealed class SessionFaultNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Values copied from ConversationSessionStateRestoreTests.ASecondSessionOfOneConversation_DropsASlotTheDocumentNoLongerDeclares. + [Fact] + public async Task AStoredSlotTheDocumentNoLongerDeclaresIsAPartialRestore() + { + InMemoryConversationStore store = new(); + _ = await store.CreateAsync("C-drift", Ct); + _ = await store.AppendAsync( + "C-drift", + [new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "hello"), "m0")], + new ConversationSessionState + { + Slots = new Dictionary(StringComparer.Ordinal) { ["retired"] = JsonValue.Create("gone") }, + }, + Ct); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("sure"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], store, conversationId: "C-drift"); + + _ = await session.RunTurnAsync("still there?", Ct); + await session.FlushNoticesAsync(); + + Fault fault = Assert.Single(hook.Of()); + Assert.Equal((FaultKind.StateRestorePartial, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal("the document no longer declares the slot 'retired'.", fault.Message); + } + + // The extractor's own reason for a reply that is not JSON (StateExtractor). + [Fact] + public async Task AnExtractorReplyThatIsNotJsonIsAFailedExtraction() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + using ScriptedChatClient extractor = new("not json"); + RoutingChatClientFactory clients = new RoutingChatClientFactory(reply).Route("fill", extractor); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(InterruptionSessions.ExtractorYaml), + new AgentCompilationContext(clients) { Hooks = [hook] })["main"]; + ConversationSession session = new ConversationSessionFactory( + compiled, new GuardEvaluator(compiled.Configuration.Guards), ConversationSessionFactory.CreateExtractor(compiled, clients)).Create(); + + _ = await session.RunTurnAsync("I am Ann", Ct); + await session.FlushNoticesAsync(); + + Fault fault = Assert.Single(hook.Of()); + Assert.Equal((FaultKind.ExtractionFailed, (int?)0), (fault.Kind, fault.Scope.TurnIndex)); + Assert.StartsWith("the extractor reply is not well-formed JSON", fault.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AStoreThatCannotPutTheBusyMarkIsAFailedMarkAndTheTurnStillRuns() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + MarkAnswers store = new(new InMemoryConversationStore(), () => throw new IOException("the store is down")); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], store); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal("hello", turn.ReplyText); + Fault fault = Assert.Single(hook.Of()); + Assert.Equal((FaultKind.BusyMarkFailed, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal("IOException: the store is down", fault.Message); + _ = Assert.IsType(fault.Cause); + } + + [Fact(Timeout = 30_000)] + public async Task AMarkAnotherSessionTookWhileTheTurnRanIsALostMark() + { + Fault fault = await RenewAsync(() => false); + + Assert.Equal((FaultKind.BusyMarkLost, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Null(fault.Cause); + } + + [Fact(Timeout = 30_000)] + public async Task ARenewalTheStoreFailsIsAFailedMark() + { + Fault fault = await RenewAsync(() => throw new IOException("the store is down")); + + Assert.Equal((FaultKind.BusyMarkFailed, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + _ = Assert.IsType(fault.Cause); + } + + /// Takes the mark, then answers its first renewal, made while a turn is still streaming. + private static async Task RenewAsync(Func renewal) + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello", " there") { GateAfterFirstFragment = true }; + MarkAnswers store = new(new InMemoryConversationStore(time), () => true, renewal); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], store, time: time); + await using IAsyncEnumerator stream = session.RunTurnStreamingAsync("hi", Ct).GetAsyncEnumerator(Ct); + Assert.True(await stream.MoveNextAsync()); + + time.Advance(ConversationBusyMark.Lease / 3); + Fault fault = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + + reply.OpenGate(); + while (await stream.MoveNextAsync()) + { + } + + return fault; + } + + /// Answers the busy-mark calls in order, then passes them to the store. + private sealed class MarkAnswers(IConversationStore inner, params Func[] answers) : DelegatingConversationStore(inner) + { + private readonly Queue> _answers = new(answers); + + public override ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + return _answers.TryDequeue(out Func? answer) + ? ValueTask.FromResult(answer()) + : base.TryMarkBusyAsync(conversationId, holder, lease, cancellationToken); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs b/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs new file mode 100644 index 00000000..f2d95913 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs @@ -0,0 +1,249 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Tests.Transcript; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class SessionHooksTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // ConversationStarted is raised after the store opens, and says how the session found it. + [Fact] + public async Task ANewConversationStartsAsNewOnceItsStoreOpens() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + await session.FlushNoticesAsync(); + Assert.Empty(hook.Notices); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + ConversationStarted started = Assert.Single(hook.Of()); + Assert.Equal(ConversationOrigin.New, started.Origin); + Assert.Equal(session.ConversationId, started.Scope.ConversationId); + Assert.Equal("main", started.Scope.Entry); + Assert.Null(started.Scope.TurnIndex); + Assert.Equal(1, started.Scope.Sequence); + Assert.NotEqual(Guid.Empty, started.Scope.SessionId); + Assert.NotEqual(Guid.Empty, started.EventId); + } + + [Fact] + public async Task AConversationTheStoreHoldsStartsAsResumed() + { + RecordingConversationStore store = new(); + string id = await ConversationSessionResumeTestSupport.FirstTurnAsync(store, "hi", "hello"); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("again"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], store, conversationId: id); + + _ = await session.RunTurnAsync("and now?", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(ConversationOrigin.Resumed, Assert.Single(hook.Of()).Origin); + } + + [Fact] + public async Task ASecondSessionOfAConversationThisProcessHeldStartsAsReloaded() + { + RecordingHook hook = new(); + RecordingConversationStore store = new(); + using ScriptedChatClient reply = new("hello"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply, [hook], store)["main"]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + + ConversationSession first = factory.Create("c1"); + _ = await first.RunTurnAsync("hi", Ct); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + ConversationSession second = factory.Create("c1"); + _ = await second.RunTurnAsync("again", Ct); + await second.FlushNoticesAsync(); + + Assert.Equal( + [ConversationOrigin.New, ConversationOrigin.Reloaded], + hook.Of().Select(started => started.Origin)); + } + + // Two entries of one compile share one runtime, so one conversation id has one Sequence. + [Fact] + public async Task TwoFactoriesOfOneCompileShareOneSequence() + { + RecordingHook hook = new(); + RecordingConversationStore store = new(); + using ScriptedChatClient reply = new("hello"); + IReadOnlyDictionary compiled = HookSessions.Compile(HookSessions.TwoEntryYaml, reply, [hook], store); + ConversationSessionFactory main = new(compiled["main"], new GuardEvaluator(compiled["main"].Configuration.Guards)); + ConversationSessionFactory other = new(compiled["other"], new GuardEvaluator(compiled["other"].Configuration.Guards)); + + ConversationSession a = main.Create("shared"); + _ = await a.RunTurnAsync("hi", Ct); + await a.FlushTranscriptAsync(); + ConversationSession b = other.Create("shared"); + _ = await b.RunTurnAsync("hi again", Ct); + await b.FlushNoticesAsync(); + + IReadOnlyList all = hook.Notices; + Assert.Equal(Enumerable.Range(1, all.Count).Select(static n => (long)n), all.Select(static notice => notice.Scope.Sequence)); + Assert.Equal(["main", "other"], hook.Of().Select(static started => started.Scope.Entry)); + long lastOfA = all.Where(notice => notice.Scope.SessionId == a.Hooks.SessionId).Max(static notice => notice.Scope.Sequence); + ConversationStarted startedB = Assert.Single(hook.Of(), started => started.Scope.SessionId == b.Hooks.SessionId); + Assert.True(startedB.Scope.Sequence > lastOfA); + } + + // A factory's own hooks get notices; a gate hook must be compiled in. + [Fact] + public async Task AFactoryHookHearsTheSessionsItBuilds() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply)["main"]; + ConversationSession session = new ConversationSessionFactory( + compiled, new GuardEvaluator(compiled.Configuration.Guards), hooks: [hook]).Create(); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + _ = Assert.Single(hook.Of()); + } + + [Fact] + public void AFactoryHookThatOverridesAGateIsRefused() + { + using ScriptedChatClient reply = new("hello"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply)["main"]; + + ArgumentException refused = Assert.Throws(() => new ConversationSessionFactory( + compiled, new GuardEvaluator(compiled.Configuration.Guards), hooks: [new ToolGateHook()])); + + Assert.Contains(nameof(AgentCompilationContext.Hooks), refused.Message, StringComparison.Ordinal); + Assert.Equal("hooks", refused.ParamName); + } + + // A loaded session pins its mailbox, so an idle eviction never restarts its Sequence. + [Fact(Timeout = 10_000)] + public async Task ALoadedSessionKeepsItsSequenceAcrossTheIdleEviction() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook], time: time); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + _ = session.Hooks.Raise(new TurnStarted(session.Hooks.Scope(turnIndex: 0, stage: string.Empty), "only", "hi")); + await session.FlushNoticesAsync(); + + IReadOnlyList all = hook.Notices; + Assert.Equal(Enumerable.Range(1, all.Count).Select(static n => (long)n), all.Select(static notice => notice.Scope.Sequence)); + Assert.Contains(all, static notice => notice is TurnStarted); + } + + [Fact(Timeout = 10_000)] + public async Task ADisposedSessionLetsItsMailboxGoOnceIdle() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + using ScriptedChatClient reply = new("hello"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply, [new RecordingHook()], time: time)["main"]; + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards), timeProvider: time).Create("c1"); + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + await session.DisposeAsync(); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + + await NoticeProbes.WaitUntilAsync(() => compiled.Hooks.Notices.MailboxCount == 0); + } + + // A dispose that throws still lets go of the pin. Here the release of a child that ignores its cancel logs a + // warning after its 5 s limit, and the logger throws. + [Fact(Timeout = 30_000)] + public async Task ASessionWhoseDisposeThrowsStillLetsItsMailboxGo() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + using DeafChatClient child = new(); + ToolCallingChatClient parent = new( + "started", + new Dictionary(StringComparer.Ordinal) { ["agentName"] = "blocker", ["input"] = "work", ["description"] = "d" }); + RoutingChatClientFactory clients = new(parent); + _ = clients.Route("parent", parent); + _ = clients.Route("blocker", child); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(HookSessions.BackgroundYaml), + new AgentCompilationContext(clients) { Hooks = [new RecordingHook()], Clock = time })["main"]; + ConversationSession session = new ConversationSessionFactory( + compiled, new GuardEvaluator(compiled.Configuration.Guards), timeProvider: time, logger: new WarningThrowingLogger()).Create("c1"); + _ = await session.RunTurnAsync("go", Ct); + await session.FlushNoticesAsync(); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Ct); + + _ = await Assert.ThrowsAsync(async () => await session.DisposeAsync()); + child.Release(); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + + await NoticeProbes.WaitUntilAsync(() => compiled.Hooks.Notices.MailboxCount == 0); + } + + // A factory that built a hub for its own hooks drains it when it is disposed. + [Fact(Timeout = 10_000)] + public async Task DisposingAFactoryWaitsForTheNoticesItsHooksStillHold() + { + HoldingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply)["main"]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards), hooks: [hook]); + ConversationSession session = factory.Create(); + _ = await session.RunTurnAsync("hi", Ct); + await hook.Entered.Task; + + Task disposing = factory.DisposeAsync().AsTask(); + + Assert.False(disposing.IsCompleted); + hook.Release.SetResult(); + await disposing; + Assert.True(hook.Finished); + } + + private sealed class HoldingHook : AgentHook + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool Finished { get; private set; } + + public override TimeSpan? NoticeTimeout => null; + + public override async ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) + { + _ = Entered.TrySetResult(); + await Release.Task.ConfigureAwait(false); + Finished = true; + } + } + + private sealed class ToolGateHook : AgentHook + { + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/SkillLoadedNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/SkillLoadedNoticeTests.cs new file mode 100644 index 00000000..b3a7a624 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/SkillLoadedNoticeTests.cs @@ -0,0 +1,74 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Skills; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + /// + /// A pinned skill is one Pinned notice per run; a load_skill that found its skill, and + /// that the redirect did not answer, is one load. + /// + public sealed class SkillLoadedNoticeTests + { + private const string SkillsYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok", skills: [warranty-returns], pinned: [shipping-claims] } + entries: + main: + agent: only + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task APinnedSkillAndASkillTheModelLoadsAreOneNoticeEach() + { + IReadOnlyList<(string, bool)> loaded = await LoadAsync("warranty-returns"); + + Assert.Equal([("shipping-claims", true), ("warranty-returns", false)], loaded); + } + + [Fact] + public async Task LoadingAPinnedSkillLoadsNothingMore() + { + IReadOnlyList<(string, bool)> loaded = await LoadAsync("shipping-claims"); + + Assert.Equal([("shipping-claims", true)], loaded); + } + + // A load_skill MAF answered "not found" loaded nothing. + [Fact] + public async Task LoadingASkillThatDoesNotExistLoadsNothing() + { + IReadOnlyList<(string, bool)> loaded = await LoadAsync("no-such-skill"); + + Assert.Equal([("shipping-claims", true)], loaded); + } + + private static async Task> LoadAsync(string skillName) + { + using SkillFolder folder = SkillFolder.Create() + .WithSkill("warranty-returns") + .WithSkill("shipping-claims"); + using AgentFileSkillsSource source = new(folder.Root); + SkillCatalog catalog = new(source, new HashSet(["warranty-returns", "shipping-claims"], StringComparer.Ordinal)); + + RecordingHook hook = new(); + ToolCallingChatClient model = new("final", new Dictionary(StringComparer.Ordinal) { ["skillName"] = skillName }); + ConversationSession session = HookSessions.Create(SkillsYaml, model, [hook], skills: catalog); + + _ = await session.RunTurnAsync("how do returns work?", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal([AgentSkillsProvider.LoadSkillToolName], model.Called); + return [.. hook.Of().Select(notice => (notice.Name, notice.Pinned))]; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/SubagentBracketTests.cs b/tests/AgentCore.Application.Tests/Hooks/SubagentBracketTests.cs new file mode 100644 index 00000000..a8e2bc48 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/SubagentBracketTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Transcript; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class SubagentBracketTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Every Started is closed by one Ended, wherever the run stops. + [Fact] + public async Task ASessionThatCannotBeCreatedStillClosesTheBracketAsFaulted() + { + (RecordingHook hook, SessionHooks hooks, TurnInvocation turn) = Open(); + SubagentAgent inner = new(createSession: () => throw new InvalidOperationException("no session")); + + _ = await Assert.ThrowsAsync(async () => + await DelegatedAgentRun.RunAsync(inner, "help", turn, tools: null, [], Ct)); + await hooks.FlushAsync(); + + _ = Assert.Single(hook.Of()); + Assert.Equal(SubagentOutcome.Faulted, Assert.Single(hook.Of()).Outcome); + } + + [Fact] + public async Task ACancelDuringTheRunClosesTheBracketAsCancelled() + { + (RecordingHook hook, SessionHooks hooks, TurnInvocation turn) = Open(); + using CancellationTokenSource cancel = CancellationTokenSource.CreateLinkedTokenSource(Ct); + SubagentAgent inner = new(run: async token => + { + await cancel.CancelAsync(); + await Task.Delay(Timeout.Infinite, token); + }); + + _ = await Assert.ThrowsAnyAsync(async () => + await DelegatedAgentRun.RunAsync(inner, "help", turn, tools: null, [], cancel.Token)); + await hooks.FlushAsync(); + + _ = Assert.Single(hook.Of()); + Assert.Equal(SubagentOutcome.Cancelled, Assert.Single(hook.Of()).Outcome); + } + + [Fact] + public async Task ACancelDuringSessionCreationClosesTheBracketAsCancelled() + { + (RecordingHook hook, SessionHooks hooks, TurnInvocation turn) = Open(); + using CancellationTokenSource cancel = new(); + await cancel.CancelAsync(); + SubagentAgent inner = new(createSession: () => throw new OperationCanceledException(cancel.Token)); + + _ = await Assert.ThrowsAnyAsync(async () => + await DelegatedAgentRun.RunAsync(inner, "help", turn, tools: null, [], cancel.Token)); + await hooks.FlushAsync(); + + Assert.Equal(SubagentOutcome.Cancelled, Assert.Single(hook.Of()).Outcome); + } + + private static (RecordingHook Hook, SessionHooks Hooks, TurnInvocation Turn) Open() + { + RecordingHook hook = new(); + HookRuntime runtime = HookRuntime.Create([hook], loggers: null); + SessionHooks hooks = new(runtime, "conversation-1", "main", TimeProvider.System); + TurnInvocation turn = new() + { + ConversationId = "conversation-1", + TurnIndex = 0, + Stage = string.Empty, + Hooks = hooks, + OuterCallId = "call-1", + }; + + return (hook, hooks, turn); + } + + private sealed class SubagentAgent(Func? createSession = null, Func? run = null) : AIAgent + { + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + return new(createSession?.Invoke() ?? new StubSession()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override async Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + if (run is not null) + { + await run(cancellationToken); + } + + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TelemetryHookSuite.cs b/tests/AgentCore.Application.Tests/Hooks/TelemetryHookSuite.cs new file mode 100644 index 00000000..d2326570 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TelemetryHookSuite.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.Metrics; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + /// The collection runs in, alone, because a hears every meter in the process. + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class TelemetryHookSuite + { + /// The name both the definition and the test class name it by. + public const string Name = "telemetry hook"; + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs new file mode 100644 index 00000000..8b4f2dd8 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs @@ -0,0 +1,167 @@ +using System.Diagnostics.Metrics; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Domain.Audit; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + [Collection(TelemetryHookSuite.Name)] + public sealed class TelemetryHookTests + { + private const string FailureInstrument = "agentcore.turn.failures"; + private const string FailureKey = "agentcore.failure.kind"; + private const string ModerationInstrument = "agentcore.moderation.verdicts"; + private const string ModerationKey = "agentcore.moderation.outcome"; + private const string AuditInstrument = "agentcore.audit.events"; + private const string AuditKey = "agentcore.audit.kind"; + + private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 1, DateTimeOffset.UnixEpoch); + + private sealed record Reading(string Instrument, string Key, string Value); + + public static TheoryData FailedTurns => new() + { + { Turn(TurnOutcome.Empty, inTool: false), "empty_reply" }, + { Turn(TurnOutcome.Fallback, inTool: true), "tool" }, + { Turn(TurnOutcome.Fallback, inTool: false), "run" }, + { Turn(TurnOutcome.Faulted, inTool: false), "run" }, + }; + + [Theory] + [MemberData(nameof(FailedTurns))] + public async Task AFailedTurnIsCountedUnderItsKind(TurnCompleted completed, string kind) + { + List measured = await MeasureAsync(hook => hook.OnTurnCompletedAsync(completed, TestContext.Current.CancellationToken)); + + Assert.Contains(new Reading(FailureInstrument, FailureKey, kind), measured); + Assert.Contains(new Reading(AuditInstrument, AuditKey, "turn.completed"), measured); + } + + [Fact] + public async Task AnAnsweredTurnIsNoFailure() + { + List measured = await MeasureAsync(hook => hook.OnTurnCompletedAsync(Turn(TurnOutcome.Answered, inTool: false), TestContext.Current.CancellationToken)); + + Assert.DoesNotContain(measured, reading => reading.Instrument == FailureInstrument); + } + + [Theory] + [InlineData(InputVerdict.Clean, "clean")] + [InlineData(InputVerdict.Flagged, "flagged")] + [InlineData(InputVerdict.Unavailable, "unavailable")] + public async Task AModerationVerdictIsCountedUnderItsOutcome(InputVerdict verdict, string outcome) + { + InputModerated moderated = new(Scope, verdict, verdict == InputVerdict.Flagged ? ["violence"] : [], null); + + List measured = await MeasureAsync(hook => hook.OnInputModeratedAsync(moderated, TestContext.Current.CancellationToken)); + + Assert.Contains(new Reading(ModerationInstrument, ModerationKey, outcome), measured); + } + + [Fact] + public async Task AnExtractionFaultIsCountedAsAnExtractionFailure() + { + List measured = await MeasureAsync(hook => hook.OnFaultAsync(new Fault(Scope, FaultKind.ExtractionFailed, "bad object", null), TestContext.Current.CancellationToken)); + + Assert.Contains(new Reading(FailureInstrument, FailureKey, "extraction"), measured); + } + + [Fact] + public async Task EveryAuditedFactIsCountedUnderItsToken() + { + List measured = await MeasureAsync(async hook => + { + CancellationToken ct = TestContext.Current.CancellationToken; + await hook.OnConversationStartedAsync(new ConversationStarted(Scope, ConversationOrigin.New), ct); + await hook.OnConversationEndedAsync(new ConversationEnded(Scope, ConversationEndReason.CallerHungUp, null, null), ct); + await hook.OnTurnRefusedAsync(new TurnRefused(Scope, TurnRefusal.Busy, AfterEnd: false), ct); + await hook.OnTurnSupersededAsync(new TurnSuperseded(Scope, 1, 2), ct); + await hook.OnReplyCutAsync(new ReplyCut(Scope, "hi", null, Guid.CreateVersion7()), ct); + await hook.OnToolCalledAsync(new ToolCalled(Scope, "t", "c", TimeSpan.Zero, ToolOutcome.Undeclared, false, ToolFailureKind.Undeclared, "x"), ct); + await hook.OnInputModeratedAsync(new InputModerated(Scope, InputVerdict.Flagged, ["violence"], null), ct); + }); + + string[] tokens = ["conversation.started", "conversation.ended", "turn.refused", "turn.superseded", "reply.interrupted", "tool.failed", "prompt.flagged"]; + Assert.All(tokens, token => Assert.Contains(new Reading(AuditInstrument, AuditKey, token), measured)); + } + + [Fact] + public async Task AFactTheChainWritesNoRowForIsNotCounted() + { + List measured = await MeasureAsync(async hook => + { + CancellationToken ct = TestContext.Current.CancellationToken; + await hook.OnTurnRefusedAsync(new TurnRefused(Scope, TurnRefusal.Terminal, AfterEnd: true), ct); + await hook.OnToolCalledAsync(new ToolCalled(Scope, "t", "c", TimeSpan.Zero, ToolOutcome.Ok, false, null, null), ct); + await hook.OnInputModeratedAsync(new InputModerated(Scope, InputVerdict.Clean, [], null), ct); + }); + + Assert.DoesNotContain(measured, reading => reading.Instrument == AuditInstrument); + } + + [Theory] + [InlineData(LatencyMetric.TimeToFirstToken, "agentcore.turn.time_to_first_token")] + [InlineData(LatencyMetric.TimeToFirstSpeech, "agentcore.turn.time_to_first_speech")] + [InlineData(LatencyMetric.TimeToReplyEnd, "agentcore.turn.time_to_reply_end")] + [InlineData(LatencyMetric.BargeIn, "agentcore.barge_in.latency")] + public async Task EachLatencyIsRecordedOnItsSignedOffHistogram(LatencyMetric metric, string instrument) + { + List<(string Instrument, double Value)> readings = []; + using MeterListener listener = new(); + listener.InstrumentPublished = (published, active) => + { + if (string.Equals(published.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(published); + } + }; + listener.SetMeasurementEventCallback((published, value, _, _) => + { + lock (readings) + { + readings.Add((published.Name, value)); + } + }); + listener.Start(); + + await new TelemetryHook().OnTurnLatencyAsync(new TurnLatency(Scope, metric, TimeSpan.FromMilliseconds(250)), TestContext.Current.CancellationToken); + + Assert.Contains((instrument, 0.25), readings); + } + + private static TurnCompleted Turn(TurnOutcome outcome, bool inTool) => + new(Scope, outcome, "hi", "x", "", "", TimeSpan.Zero, outcome == TurnOutcome.Answered ? null : "why", inTool, null); + + private static async Task> MeasureAsync(Func deliver) + { + List readings = []; + using MeterListener listener = new(); + listener.InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, _, tags, _) => + { + KeyValuePair[] copy = tags.ToArray(); + lock (readings) + { + readings.AddRange(copy.Select(tag => new Reading(instrument.Name, tag.Key, tag.Value?.ToString() ?? string.Empty))); + } + }); + listener.Start(); + + await deliver(new TelemetryHook()); + + lock (readings) + { + return [.. readings]; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs new file mode 100644 index 00000000..fe9eeedc --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs @@ -0,0 +1,97 @@ +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Tools; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ToolCalledNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AToolThatAnswersIsOneOkNotice() + { + RecordingHook hook = new(); + ToolCallingChatClient model = new("final"); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [hook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushNoticesAsync(); + + ToolCalled called = Assert.Single(hook.Of()); + Assert.Equal(("price_lookup", ToolOutcome.Ok, false), (called.ToolName, called.Outcome, called.Fatal)); + } + + // A fault the model cannot answer is one Failed, fatal notice per call. + [Fact] + public async Task AnUnreachableEndpointIsOneFailedFatalNotice() + { + RecordingHook hook = new(); + using NamedToolCallingChatClient model = new("price_lookup", "Sorry."); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, model, [hook], tools: new UnreachableEndpointToolBuilder().Create); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushNoticesAsync(); + + ToolCalled called = Assert.Single(hook.Of()); + Assert.Equal((ToolOutcome.Failed, true, ToolFailureKind.Faulted), (called.Outcome, called.Fatal, called.FailureKind)); + Assert.NotNull(called.Failure); + } + + // Values from ConversationSessionAuditToolFailureTests.AHallucinatedToolName_ReachesTheChain. + [Fact] + public async Task AToolTheModelInventedIsOneUndeclaredNotice() + { + RecordingHook hook = new(); + using NamedToolCallingChatClient model = new("lookup_ordar", "Let me try that again."); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, model, [hook], tools: new StubToolBuilder("{}").Create); + + _ = await session.RunTurnAsync("where is my order", Ct); + await session.FlushNoticesAsync(); + + ToolCalled called = Assert.Single(hook.Of()); + Assert.Equal(("lookup_ordar", ToolOutcome.Undeclared, ToolFailureKind.Undeclared), (called.ToolName, called.Outcome, called.FailureKind)); + Assert.Equal(model.CallIds[0], called.CallId); + } + + [Fact] + public async Task ATimeLimitMarksTheCallTimedOut() + { + AIFunction slow = AIFunctionFactory.Create(async (CancellationToken token) => { await Task.Delay(Timeout.Infinite, token); return "late"; }, "slow"); + TimeLimitedTool limited = new(slow, TimeSpan.FromMilliseconds(20)); + AIFunctionArguments arguments = new(); + + _ = await limited.InvokeAsync(arguments, Ct); + + Assert.Equal(ToolOutcome.TimedOut, ToolCallOutcomes.Of(arguments)); + } + + [Fact] + public async Task ACachedAnswerMarksTheCallCached() + { + ServiceCollection services = new(); + _ = services.AddHybridCache(); + HybridCache cache = services.BuildServiceProvider().GetRequiredService(); + CachedTool cached = new(AIFunctionFactory.Create(() => "fresh", "lookup"), cache, TimeSpan.FromMinutes(1)); + + AIFunctionArguments first = new(); + AIFunctionArguments second = new(); + _ = await cached.InvokeAsync(first, Ct); + _ = await cached.InvokeAsync(second, Ct); + + Assert.Equal(ToolOutcome.Ok, ToolCallOutcomes.Of(first)); + Assert.Equal(ToolOutcome.Cached, ToolCallOutcomes.Of(second)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs new file mode 100644 index 00000000..e2c925a1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs @@ -0,0 +1,250 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class ToolGateTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The tool does not run; the model reads the block as the tool's answer. + [Fact] + public async Task ABlockedToolNeverRunsAndTheModelReadsTheBlock() + { + StubToolBuilder stub = new("""{ "price": 50 }"""); + ToolCallingChatClient model = new("final"); + Gate hook = new(before: gate => gate.Block("not allowed here")); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [hook, notices], tools: stub.Create); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushNoticesAsync(); + + Assert.Empty(stub.Called); + Assert.Contains(model.ToolResults, result => result.Contains("not allowed here", StringComparison.Ordinal)); + Assert.Equal(ToolOutcome.Blocked, Assert.Single(notices.Of()).Outcome); + } + + // Replaced arguments reach the tool, and the turn stays filed beside them. + [Fact] + public async Task ReplacedArgumentsReachTheToolAndKeepTheTurnFiled() + { + List<(string Sku, bool Filed)> seen = []; + ToolCallingChatClient model = new("final", new Dictionary(StringComparer.Ordinal) { ["sku"] = "A1" }); + Gate hook = new(before: gate => gate.ReplaceArguments(new Dictionary(StringComparer.Ordinal) { ["sku"] = "B2" })); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [hook], + tools: tool => AIFunctionFactory.Create( + (string sku, AIFunctionArguments arguments) => + { + seen.Add((sku, TurnInvocation.FiledIn(arguments) is not null)); + return "ok"; + }, + tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal([("B2", true)], seen); + } + + // The tool gets the replacement, the stored call keeps what the model sent. + [Fact] + public async Task ReplacedArgumentsLeaveTheModelsCallRecordAsSent() + { + RecordingConversationStore store = new(); + ToolCallingChatClient model = new("final", new Dictionary(StringComparer.Ordinal) { ["sku"] = "A1" }); + Gate hook = new(before: gate => gate.ReplaceArguments(new Dictionary(StringComparer.Ordinal) { ["sku"] = "B2" })); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, model, [hook], store, tools: tool => AIFunctionFactory.Create((string sku) => sku, tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushTranscriptAsync(); + + FunctionCallContent call = Assert.Single(store.Rows.SelectMany(row => row.Content.Contents).OfType()); + Assert.Equal("A1", call.Arguments!["sku"]?.ToString()); + Assert.Contains(model.ToolResults, result => result.Contains("B2", StringComparison.Ordinal)); + } + + // Hooks and tools share one per-turn dictionary, through ToolCallScope.Items. + [Fact] + public async Task AHookAndAToolShareTheTurnsItems() + { + List seenByTool = []; + ToolCallingChatClient model = new("final"); + Gate hook = new(before: gate => gate.Items["from"] = "hook"); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [hook], + tools: tool => AIFunctionFactory.Create( + (AIFunctionArguments arguments) => + { + seenByTool.Add(ToolCallScopes.From(TurnInvocation.FiledIn(arguments)!).Items!["from"]); + return "ok"; + }, + tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal(["hook"], seenByTool); + } + + // The model sees the second attempt's result. + [Fact] + public async Task AFailedToolCanBeRetriedAndTheModelReadsTheRetry() + { + int attempts = 0; + ToolCallingChatClient model = new("final"); + Gate hook = new(failed: gate => gate.Retry()); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [hook, notices], + tools: tool => AIFunctionFactory.Create( + () => ++attempts == 1 ? throw new InvalidOperationException("first attempt") : "second attempt", + tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(2, attempts); + Assert.Contains(model.ToolResults, result => result.Contains("second attempt", StringComparison.Ordinal)); + Assert.Equal(ToolOutcome.Ok, Assert.Single(notices.Of()).Outcome); + } + + // Retry is capped at 2 per call. + [Fact] + public async Task RetryIsCappedAtTwo() + { + int attempts = 0; + ToolCallingChatClient model = new("final"); + Gate hook = new(failed: gate => gate.Retry()); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [hook], + tools: tool => AIFunctionFactory.Create( + string () => { attempts++; throw new InvalidOperationException("always"); }, + tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal(3, attempts); + } + + // CanRetry is false on the last attempt the cap allows, so a hook that checks it + // reaches its own answer. + [Fact] + public async Task CanRetryIsFalseOnTheLastAttempt() + { + int attempts = 0; + List canRetry = []; + ToolCallingChatClient model = new("final"); + Gate hook = new(failed: gate => + { + canRetry.Add(gate.CanRetry); + if (gate.CanRetry) + { + gate.Retry(); + } + else + { + gate.Respond("give up"); + } + }); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + [hook], + tools: tool => AIFunctionFactory.Create( + string () => { attempts++; throw new InvalidOperationException("always"); }, + tool.Id)); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal([true, true, false], canRetry); + Assert.Equal(3, attempts); + Assert.Contains(model.ToolResults, result => result.Contains("give up", StringComparison.Ordinal)); + } + + // ReplaceResult changes what the model reads; EndLoop stops the loop after the call. + [Fact] + public async Task AnAfterToolHookReplacesTheResultAndCanEndTheLoop() + { + StubToolBuilder stub = new("""{ "price": 50 }"""); + ToolCallingChatClient replaced = new("final"); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, replaced, [new Gate(after: gate => gate.ReplaceResult("redacted"))], tools: stub.Create); + _ = await session.RunTurnAsync("how much?", Ct); + + ToolCallingChatClient ended = new("final"); + ConversationSession second = HookSessions.Create( + HookSessions.ToolAgentYaml, ended, [new Gate(after: gate => gate.EndLoop())], tools: new StubToolBuilder("{}").Create); + _ = await second.RunTurnAsync("how much?", Ct); + + Assert.Contains(replaced.ToolResults, result => result.Contains("redacted", StringComparison.Ordinal)); + Assert.Equal(1, ended.Calls); + } + + // A hook that fails closed after the tool ran withholds the tool's result from the model. + [Fact] + public async Task AnAfterToolHookThatFailsClosedWithholdsTheResult() + { + StubToolBuilder stub = new("""{ "price": 50 }"""); + ToolCallingChatClient model = new("final"); + ConversationSession session = HookSessions.Create(HookSessions.ToolAgentYaml, model, [new ClosedAfterTool()], tools: stub.Create); + + _ = await session.RunTurnAsync("how much?", Ct); + + Assert.Equal(["price_lookup"], stub.Called); + string result = Assert.Single(model.ToolResults); + Assert.Contains("a hook withheld this result.", result, StringComparison.Ordinal); + Assert.DoesNotContain("50", result, StringComparison.Ordinal); + } + + private sealed class ClosedAfterTool : AgentHook + { + public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + + public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => + throw new InvalidOperationException("boom"); + } + + private sealed class Gate( + Action? before = null, + Action? after = null, + Action? failed = null) : AgentHook + { + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + before?.Invoke(gate); + return default; + } + + public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) + { + after?.Invoke(gate); + return default; + } + + public override ValueTask AfterToolFailedAsync(ToolFailureGate gate, CancellationToken cancellationToken) + { + failed?.Invoke(gate); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/ToolHookStackingTests.cs b/tests/AgentCore.Application.Tests/Hooks/ToolHookStackingTests.cs new file mode 100644 index 00000000..8946f673 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/ToolHookStackingTests.cs @@ -0,0 +1,113 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Hooks +{ + /// + /// MAF 1.21.0's function middleware wraps the run options it is handed in place, and a loop: entry hands + /// every round the same options. The tool layer must still run once per call. + /// + public sealed class ToolHookStackingTests + { + private const string LoopYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: coder, instructions: "fix bugs", todos: true, loop: { maxRounds: 2, until: [{ todos: {} }] } } + entries: + main: + agent: coder + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // An open todo keeps the loop going, so each of the two rounds makes one todos_add call (LoopCompilationTests). + [Fact] + public async Task EachCallOfALoopEntryIsOneNotice() + { + RecordingHook hook = new(); + ToolCallingChatClient model = new( + "added", + new Dictionary(StringComparer.Ordinal) + { + ["todos"] = new object[] { new Dictionary(StringComparer.Ordinal) { ["title"] = "T" } }, + }, + everyRun: true); + ConversationSession session = HookSessions.Create(LoopYaml, model, [hook]); + + _ = await session.RunTurnAsync("fix it", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(["todos_add", "todos_add"], model.Called); + Assert.Equal(["todos_add", "todos_add"], hook.Of().Select(called => called.ToolName)); + } + + [Fact] + public async Task TwoRunsWithOneOptionsObjectAreOneNoticePerCall() + { + RecordingHook hook = new(); + + int calls = await RunTwiceAsync([hook], () => "ok"); + + Assert.Equal(2, calls); + Assert.Equal(2, hook.Of().Count); + } + + // Three attempts per call, however many runs share the options. + [Fact] + public async Task ARetryInTwoRunsWithOneOptionsObjectStopsAtThreeAttemptsEach() + { + int calls = await RunTwiceAsync([new Retrying()], string () => throw new InvalidOperationException("always")); + + Assert.Equal(6, calls); + } + + /// Runs one gated agent twice with one options object, the way LoopAgent runs its rounds. + /// How many times the tool ran. + private static async Task RunTwiceAsync(IReadOnlyList hooks, Func answer) + { + int calls = 0; + AIFunction tool = AIFunctionFactory.Create( + () => + { + calls++; + return answer(); + }, + "price_lookup"); + ToolCallingChatClient model = new("final"); + ChatClientAgent inner = new(model, new ChatClientAgentOptions { ChatOptions = new ChatOptions { Tools = [tool] } }); + await using HookRuntime runtime = HookRuntime.Create(hooks, loggers: null); + SessionHooks session = new(runtime, "conversation", "main", TimeProvider.System); + TurnInvocation turn = new() { ConversationId = "conversation", TurnIndex = 0, Stage = string.Empty, Hooks = session }; + AIAgent agent = ToolHookMiddleware.Apply(inner, runtime); + AgentRunOptions options = turn.RunOptions(); + + _ = await agent.RunAsync("how much?", session: null, options, Ct); + _ = await agent.RunAsync("how much?", session: null, options, Ct); + await session.FlushAsync(); + session.Release(); + + return calls; + } + + private sealed class Retrying : AgentHook + { + public override ValueTask AfterToolFailedAsync(ToolFailureGate gate, CancellationToken cancellationToken) + { + gate.Retry(); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs new file mode 100644 index 00000000..d34dc309 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs @@ -0,0 +1,159 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Evaluation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Evaluation.Fakes; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class TurnGateTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task ABlockedTurnSpeaksTheBlockAndNeverReachesTheModel() + { + RecordingConversationStore store = new(); + ToolCallingChatClient model = new("never"); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, model, [new Gate(gate => gate.Block("not today")), notices], store); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + Assert.Equal("not today", turn.ReplyText); + Assert.Equal(0, model.Calls); + Assert.Equal(TurnOutcome.Blocked, Assert.Single(notices.Of()).Outcome); + Assert.Contains(store.Live(session.ConversationId), row => row.Content.Role == ChatRole.Assistant && row.Content.Text == "not today"); + } + + [Fact] + public async Task AReplacedInputIsWhatEveryoneDownstreamSees() + { + RecordingConversationStore store = new(); + RequestCapturingChatClient capture = new(new ScriptedChatClient("ok")); + ScriptedModerationEvaluator moderation = ScriptedModerationEvaluator.Clean(); + RecordingHook notices = new(); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(HookSessions.OneAgentYaml), + new AgentCompilationContext(new FakeChatClientFactory(capture)) + { + ConversationStore = store, + Moderation = new PromptModerator(moderation), + Hooks = [new Gate(gate => gate.ReplaceInput("my card is [redacted]")), notices], + })["main"]; + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(); + + _ = await session.RunTurnAsync("my card is 4111", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + + Assert.Contains(capture.Requests[0], message => message.Role == ChatRole.User && message.Text == "my card is [redacted]"); + Assert.DoesNotContain(capture.Requests[0], message => message.Text.Contains("4111", StringComparison.Ordinal)); + Assert.Equal(["my card is [redacted]"], moderation.Moderated); + ConversationMessage user = Assert.Single(store.Live(session.ConversationId), row => row.Content.Role == ChatRole.User); + Assert.Equal("my card is [redacted]", user.Content.Text); + Assert.Equal("my card is [redacted]", Assert.Single(notices.Of()).UserText); + } + + [Fact] + public async Task AddedContextReachesTheModelAndIsNeverStored() + { + RecordingConversationStore store = new(); + RequestCapturingChatClient capture = new(new ScriptedChatClient("ok")); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, capture, [new Gate(gate => gate.AddContext("the caller is a VIP"))], store); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushTranscriptAsync(); + + Assert.Contains(capture.Requests[0], message => message.Role == ChatRole.System && message.Text == "the caller is a VIP"); + Assert.DoesNotContain(store.Live(session.ConversationId), row => row.Content.Text.Contains("VIP", StringComparison.Ordinal)); + } + + [Fact] + public async Task ATurnWithNoDecisionRunsAsBefore() + { + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, new ScriptedChatClient("hello"), [new Gate(_ => { })]); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + + Assert.Equal("hello", turn.ReplyText); + } + + [Fact] + public async Task AHookThatThrowsFailsOpenAndTheTurnRuns() + { + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, new ScriptedChatClient("hello"), [new Gate(_ => throw new InvalidOperationException("boom")), notices]); + + TurnResult turn = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal("hello", turn.ReplyText); + Assert.Equal(FaultKind.HookFailed, Assert.Single(notices.Of()).Kind); + } + + [Fact] + public async Task AHookThatFailsClosedBlocksTheTurnWithTheRefusalReply() + { + ToolCallingChatClient model = new("never"); + RecordingHook notices = new(); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, model, [new ClosedGate(), notices]); + + TurnResult turn = await session.RunTurnAsync("my card is 4111", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal("I am sorry. I cannot help with that request.", turn.ReplyText); + Assert.Equal(0, model.Calls); + Assert.Equal(TurnOutcome.Blocked, Assert.Single(notices.Of()).Outcome); + } + + [Fact] + public async Task AddedContextBelongsToItsTurnOnly() + { + RequestCapturingChatClient capture = new(new ScriptedChatClient("ok")); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, + capture, + [new Gate(gate => { if (gate.UserText == "first") { gate.AddContext("first-turn note"); } })]); + + _ = await session.RunTurnAsync("first", Ct); + _ = await session.RunTurnAsync("second", Ct); + + Assert.Contains(capture.Requests[0], message => message.Text == "first-turn note"); + Assert.DoesNotContain(capture.Requests[1], message => message.Text == "first-turn note"); + } + + private sealed class ClosedGate : AgentHook + { + public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + + public override ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) => + throw new InvalidOperationException("boom"); + } + + private sealed class Gate(Action decide) : AgentHook + { + public override ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) + { + decide(gate); + return default; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TurnNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/TurnNoticeTests.cs new file mode 100644 index 00000000..6ac6cfa9 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TurnNoticeTests.cs @@ -0,0 +1,162 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class TurnNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A turn starts, then completes, with the caller's and the reply's words. + [Fact] + public async Task AnAnsweredTurnStartsThenCompletes() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello", " there"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + TurnStarted started = Assert.Single(hook.Of()); + TurnCompleted completed = Assert.Single(hook.Of()); + Assert.Equal("only", started.AgentId); + Assert.Equal("hi", started.UserText); + Assert.Equal(0, started.Scope.TurnIndex); + Assert.Equal(TurnOutcome.Answered, completed.Outcome); + Assert.Equal("hi", completed.UserText); + Assert.Equal("hello there", completed.ReplyText); + Assert.Null(completed.Failure); + Assert.False(completed.FailedInTool); + Assert.True(started.Scope.Sequence < completed.Scope.Sequence); + } + + // A tool that spent its budget makes a Fallback turn that says the tool failed. + [Fact] + public async Task AToolThatSpentItsBudgetIsAFallbackTurnFailedInTheTool() + { + RecordingHook hook = new(); + using LoopingToolCallingChatClient reply = new(); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, reply, [hook], tools: new ThrowingToolBuilder().Create); + + _ = await session.RunTurnAsync("where is my order", Ct); + await session.FlushNoticesAsync(); + + TurnCompleted completed = Assert.Single(hook.Of()); + Assert.Equal(TurnOutcome.Fallback, completed.Outcome); + Assert.True(completed.FailedInTool); + Assert.Contains(ThrowingToolBuilder.Message, completed.Cause?.Message, StringComparison.Ordinal); + } + + // No text is an Empty turn that spoke the fallback. + [Fact] + public async Task AQuietRunIsAnEmptyTurn() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new(" "); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + TurnCompleted completed = Assert.Single(hook.Of()); + Assert.Equal(TurnOutcome.Empty, completed.Outcome); + Assert.Equal(TurnFailureReasons.EmptyReply, completed.Failure); + } + + // Terminal refusals raised nothing). + [Fact] + public async Task AStageMoveIsNamedAndATurnOnATerminalConversationIsRefused() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("done"); + ConversationSession session = HookSessions.Create(HookSessions.EndsAfterTheFirstTurnYaml, reply, [hook]); + + _ = await session.RunTurnAsync("hi", Ct); + _ = await Assert.ThrowsAsync(() => session.RunTurnAsync("again", Ct)); + await session.FlushNoticesAsync(); + + StageChanged moved = Assert.Single(hook.Of()); + Assert.Equal(("working", "done"), (moved.Before, moved.After)); + TurnRefused refused = Assert.Single(hook.Of()); + Assert.Equal(TurnRefusal.Terminal, refused.Reason); + Assert.True(refused.AfterEnd); + } + + // The end waits for the running turn, but a refusal in that wait already says the conversation ended. + [Fact] + public async Task ARefusalWhileTheEndWaitsForTheTurnSaysSo() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("Hello", " there") { GateAfterFirstFragment = true }; + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + await using IAsyncEnumerator stream = session.RunTurnStreamingAsync("hi", Ct).GetAsyncEnumerator(Ct); + Assert.True(await stream.MoveNextAsync()); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + _ = await Assert.ThrowsAsync(() => session.RunTurnAsync("again", Ct)); + await session.FlushNoticesAsync(); + + Assert.Empty(hook.Of()); + TurnRefused refused = Assert.Single(hook.Of()); + Assert.Equal(TurnRefusal.Terminal, refused.Reason); + Assert.True(refused.AfterEnd); + + reply.OpenGate(); + while (await stream.MoveNextAsync()) + { + } + } + + // Values copied from ConversationSessionEditTests.AnEdit_NamesTheTurnsItWithdrew. + [Fact] + public async Task AnEditNamesTheTurnsItWithdrew() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("an answer."); + ConversationSession session = HookSessions.Create( + HookSessions.OneAgentYaml, reply, [hook], new InMemoryConversationStore(), conversationId: "conversation-1"); + + _ = await session.RunTurnAtOriginAsync("q1", new ConversationTurnOrigin("caller-1", null) { NamesParent = true }, Ct); + string? firstReply = session.LastReplyMessageId; + _ = await session.RunTurnAsync("q2", Ct); + _ = await session.RunTurnAsync("q3", Ct); + _ = await session.RunTurnAtOriginAsync("q2, rewritten", new ConversationTurnOrigin("caller-4", firstReply) { NamesParent = true }, Ct); + await session.FlushNoticesAsync(); + + TurnSuperseded superseded = Assert.Single(hook.Of()); + Assert.Equal((1, 2), (superseded.WithdrewFrom, superseded.WithdrewThrough)); + Assert.Equal(3, superseded.Scope.TurnIndex); + } + + // Each output update that leaves the turn. + [Fact] + public async Task EachUpdateTheCallerGetsIsANotice() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello", " there"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + List got = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", Ct)) + { + got.Add(update.Text); + } + + await session.FlushNoticesAsync(); + + Assert.Equal(got.Where(text => text.Length > 0), hook.Of().Select(notice => notice.Text)); + Assert.Equal(["hello", " there"], hook.Of().Select(notice => notice.Text)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TurnOutcomesTests.cs b/tests/AgentCore.Application.Tests/Hooks/TurnOutcomesTests.cs new file mode 100644 index 00000000..7c562013 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TurnOutcomesTests.cs @@ -0,0 +1,43 @@ +using AgentCore.Application.Hooks.Notices; +using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class TurnOutcomesTests + { + // Blocked = moderation flagged; Cancelled = sealed with the stop cut (ShownText and Played both + // null); Empty = no fault and no text; Fallback = a fault the fallback layer caught; Faulted = a fault that + // escaped every layer to the seal; else Answered. + [Theory] + [InlineData(null, false, false, "none", "none", TurnOutcome.Answered)] + [InlineData(null, false, false, "clean", "none", TurnOutcome.Answered)] + [InlineData(null, false, false, "none", "partial", TurnOutcome.Answered)] + [InlineData("empty_reply", false, false, "none", "none", TurnOutcome.Empty)] + [InlineData(null, true, false, "none", "none", TurnOutcome.Fallback)] + [InlineData("tool", true, true, "none", "none", TurnOutcome.Faulted)] + [InlineData(null, false, false, "flagged", "none", TurnOutcome.Blocked)] + [InlineData(null, false, false, "none", "stop", TurnOutcome.Cancelled)] + public void EachSealedTurnReadsAsItsDecidedOutcome( + string? failure, bool faulted, bool escaped, string moderation, string cut, TurnOutcome expected) + { + ReplyOutcome outcome = new( + "words", "words", failure, ToolFault: null, InterruptedAfter: null, Approvals: [], Fault: faulted ? new InvalidOperationException("down") : null); + TurnDisposition? disposition = moderation switch + { + "clean" => new TurnDisposition(ModerationOutcome.Clean, null, FallbackCause.None, null, null), + "flagged" => new TurnDisposition(ModerationOutcome.Flagged, "violence", FallbackCause.None, null, null), + _ => null, + }; + TurnCut? turnCut = cut switch + { + "partial" => new TurnCut("shown so far", TimeSpan.FromSeconds(2)), + "stop" => new TurnCut(null, null), + _ => null, + }; + + Assert.Equal(expected, TurnOutcomes.Of(outcome, disposition, turnCut, escaped)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TurnRefusalTokensTests.cs b/tests/AgentCore.Application.Tests/Hooks/TurnRefusalTokensTests.cs new file mode 100644 index 00000000..04eae9f3 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TurnRefusalTokensTests.cs @@ -0,0 +1,28 @@ +using AgentCore.Application.Hooks.Notices; +using Xunit; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class TurnRefusalTokensTests + { + // The refusedReason tokens: busy, conflict, gone, dropped, terminal, disposed and in_use. + [Theory] + [InlineData(TurnRefusal.Busy, "busy")] + [InlineData(TurnRefusal.Conflict, "conflict")] + [InlineData(TurnRefusal.Gone, "gone")] + [InlineData(TurnRefusal.Dropped, "dropped")] + [InlineData(TurnRefusal.Terminal, "terminal")] + [InlineData(TurnRefusal.Disposed, "disposed")] + [InlineData(TurnRefusal.InUse, "in_use")] + public void EachRefusalHasItsAuditToken(TurnRefusal refusal, string token) + { + Assert.Equal(token, TurnRefusalTokens.ToToken(refusal)); + } + + [Fact] + public void AValueOutsideTheSetIsRefused() + { + _ = Assert.Throws(() => TurnRefusalTokens.ToToken((TurnRefusal)99)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/TurnRefusedNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/TurnRefusedNoticeTests.cs new file mode 100644 index 00000000..c4141cfc --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/TurnRefusedNoticeTests.cs @@ -0,0 +1,108 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Transcript; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class TurnRefusedNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A disposed session's refusal raised nothing. + [Fact] + public async Task ATurnOnADisposedSessionIsRefusedAsDisposed() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + _ = await session.RunTurnAsync("hi", Ct); + await session.DisposeAsync(); + + _ = await Assert.ThrowsAsync(() => session.RunTurnAsync("again", Ct)); + await session.FlushNoticesAsync(); + + Assert.Equal(TurnRefusal.Disposed, Assert.Single(hook.Of()).Reason); + } + + // in_use is the refusal of a conversation another entry holds; it reaches the holder's hooks. + [Fact] + public async Task AConversationAnotherEntryHoldsIsRefusedAsInUse() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + IReadOnlyDictionary compiled = HookSessions.Compile(HookSessions.TwoEntryYaml, reply, [hook]); + Dictionary factories = new(StringComparer.Ordinal) + { + ["main"] = new ConversationSessionFactory(compiled["main"], new GuardEvaluator(compiled["main"].Configuration.Guards)), + ["other"] = new ConversationSessionFactory(compiled["other"], new GuardEvaluator(compiled["other"].Configuration.Guards)), + }; + using InMemoryConversationSessions sessions = new(factories, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + + ConversationSession held = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + _ = await Assert.ThrowsAsync(() => sessions.GetOrOpenAsync("other", "c1", state: null, Ct).AsTask()); + await held.FlushNoticesAsync(); + + TurnRefused refused = Assert.Single(hook.Of()); + Assert.Equal(TurnRefusal.InUse, refused.Reason); + Assert.Equal("c1", refused.Scope.ConversationId); + } + + // A turn the store refused committed nothing, so it moved no stage: only the session that saved the turn says so. + [Fact(Timeout = 30_000)] + public async Task AStoreRefusedTurnRaisesNoStageMove() + { + RecordingHook hook = new(); + HeldFirstTurnChatClient reply = new("I am Alice"); + CompiledAgent compiled = HookSessions.Compile( + HookSessions.EndsAfterTheFirstTurnYaml, + reply, + [hook], + new ConversationSessionRefusedCatchUpTests.UnmarkableStore(new InMemoryConversationStore()))["main"]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + ConversationSession a = factory.Create("c1"); + ConversationSession b = factory.Create("c1"); + + Task alice = a.RunTurnAsync("I am Alice", Ct); + await reply.Held.Task.WaitAsync(Ct); + _ = await b.RunTurnAsync("I am Bob", Ct); + reply.Release(); + _ = await Assert.ThrowsAsync(() => alice); + await a.FlushNoticesAsync(); + + Assert.Equal(TurnRefusal.Conflict, Assert.Single(hook.Of()).Reason); + StageChanged moved = Assert.Single(hook.Of()); + Assert.Equal(b.Hooks.SessionId, moved.Scope.SessionId); + Assert.True(moved.Scope.Sequence < hook.Of().Single(completed => completed.Scope.SessionId == b.Hooks.SessionId).Scope.Sequence); + } + + // AbandonRun (ConversationTurnStream.cs:239-251): a run disposed unread is refused as dropped, with its index. + [Fact] + public async Task ARunDisposedUnreadIsRefusedAsDropped() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "hi"), origin: null, Ct); + await run.DisposeAsync(); + await session.FlushNoticesAsync(); + + TurnRefused refused = Assert.Single(hook.Of()); + Assert.Equal(TurnRefusal.Dropped, refused.Reason); + Assert.False(refused.AfterEnd); + Assert.Equal(0, refused.Scope.TurnIndex); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/UnloadNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/UnloadNoticeTests.cs new file mode 100644 index 00000000..b8280dba --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/UnloadNoticeTests.cs @@ -0,0 +1,164 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Hooks +{ + public sealed class UnloadNoticeTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task ACloseUnloadsTheConversationAsClosed() + { + RecordingHook hook = new(); + using InMemoryConversationSessions sessions = Sessions(hook, TimeProvider.System, InMemoryConversationSessions.DefaultIdleTimeout); + + ConversationSession session = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + _ = await session.RunTurnAsync("hi", Ct); + await sessions.CloseAsync("main", "c1", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(UnloadCause.Closed, Assert.Single(hook.Of()).Cause); + } + + [Fact(Timeout = 30_000)] + public async Task AnIdleSessionUnloadsAsIdle() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using InMemoryConversationSessions sessions = Sessions(hook, time, TimeSpan.FromMinutes(1)); + + ConversationSession session = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + _ = await session.RunTurnAsync("hi", Ct); + await time.WaitForTimersAsync(time.GetUtcNow() + TimeSpan.FromMinutes(1), 1); + time.Advance(TimeSpan.FromMinutes(1)); + + ConversationUnloaded unloaded = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + Assert.Equal(UnloadCause.Idle, unloaded.Cause); + } + + // One session's notices are numbered 1..N with no gap. The idle unload here falls on the same + // tick as the reader's idle check, which evicts a mailbox no session pins any more. + [Fact(Timeout = 30_000)] + public async Task AnIdleUnloadOnTheReadersIdleTickStillContinuesTheSequence() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using InMemoryConversationSessions sessions = Sessions(hook, time, NoticeHub.IdleAfter, hookClock: time); + + ConversationSession session = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 2); + time.Advance(NoticeHub.IdleAfter); + + ConversationUnloaded unloaded = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + IReadOnlyList notices = hook.Notices; + Assert.Equal(Enumerable.Range(1, notices.Count).Select(i => (long)i), notices.Select(n => n.Scope.Sequence)); + Assert.Equal(hook.Of().Single().Scope.SessionId, unloaded.Scope.SessionId); + } + + // A host that disposes a session its owner holds does not unpin the mailbox before the unload, + // so the owner's unload still continues the session's 1..N. + [Fact(Timeout = 30_000)] + public async Task AnUnloadAfterTheHostDisposedTheSessionStillContinuesTheSequence() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + RecordingHook hook = new(); + using InMemoryConversationSessions sessions = Sessions(hook, time, InMemoryConversationSessions.DefaultIdleTimeout, hookClock: time); + + ConversationSession session = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + await session.DisposeAsync(); + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1); + time.Advance(NoticeHub.IdleAfter); + + // The reader arms its next idle wait only once it handled this tick, evicting the mailbox or not. + await time.WaitForTimersAsync(time.GetUtcNow() + NoticeHub.IdleAfter, 1).WaitAsync(TimeSpan.FromSeconds(10), Ct); + await sessions.CloseAsync("main", "c1", Ct); + + ConversationUnloaded unloaded = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + IReadOnlyList notices = hook.Notices; + Assert.Equal(Enumerable.Range(1, notices.Count).Select(i => (long)i), notices.Select(n => n.Scope.Sequence)); + Assert.Equal(UnloadCause.Closed, unloaded.Cause); + } + + // An unload drops nothing, so a dispose step that throws still unloads. + [Fact(Timeout = 30_000)] + public async Task AnUnloadWhoseDisposeThrowsIsStillRaised() + { + RecordingHook hook = new(); + using DeafChatClient child = new(); + ToolCallingChatClient parent = new( + "started", + new Dictionary(StringComparer.Ordinal) { ["agentName"] = "blocker", ["input"] = "work", ["description"] = "d" }); + RoutingChatClientFactory clients = new(parent); + _ = clients.Route("parent", parent); + _ = clients.Route("blocker", child); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(HookSessions.BackgroundYaml), + new AgentCompilationContext(clients) { Hooks = [hook] })["main"]; + Dictionary factories = new(StringComparer.Ordinal) + { + ["main"] = new ConversationSessionFactory( + compiled, new GuardEvaluator(compiled.Configuration.Guards), logger: new WarningThrowingLogger()), + }; + using InMemoryConversationSessions sessions = new(factories, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + ConversationSession session = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + _ = await session.RunTurnAsync("go", Ct); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Ct); + + _ = await Assert.ThrowsAsync(async () => await sessions.CloseAsync("main", "c1", Ct)); + child.Release(); + await session.FlushNoticesAsync(); + + Assert.Equal(UnloadCause.Closed, Assert.Single(hook.Of()).Cause); + } + + // An end still waiting on a turn the unload outlived goes out before the unload, never after it. + [Fact(Timeout = 30_000)] + public async Task AnEndWaitingOnTheTurnIsRaisedBeforeTheUnload() + { + RecordingHook hook = new(); + using InMemoryConversationSessions sessions = Sessions(hook, TimeProvider.System, InMemoryConversationSessions.DefaultIdleTimeout); + ConversationSession session = await sessions.GetOrOpenAsync("main", "c1", state: null, Ct); + TurnRun unread = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "hi"), origin: null, Ct); + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + + Task closing = sessions.CloseAsync("main", "c1", Ct).AsTask(); + await unread.DisposeAsync(); + await closing; + await session.FlushNoticesAsync(); + + Assert.Equal( + [typeof(ConversationEnded), typeof(ConversationUnloaded)], + hook.Notices.Where(n => n is ConversationEnded or ConversationUnloaded).Select(n => n.GetType())); + } + + private static InMemoryConversationSessions Sessions(RecordingHook hook, TimeProvider time, TimeSpan idle, TimeProvider? hookClock = null) + { + ScriptedChatClient reply = new("hello"); + CompiledAgent compiled = HookSessions.Compile(HookSessions.OneAgentYaml, reply, [hook], time: hookClock)["main"]; + Dictionary factories = new(StringComparer.Ordinal) + { + ["main"] = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards), timeProvider: time), + }; + + return new InMemoryConversationSessions(factories, idle, time); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs b/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs new file mode 100644 index 00000000..e2f3bdea --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Hooks +{ + /// Streams one text update and one usage update. + internal sealed class UsageModel : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "hi")) + { + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop, + Usage = new UsageDetails { InputTokenCount = 11, OutputTokenCount = 3, CachedInputTokenCount = 5 }, + }); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ChatResponse response = await GetResponseAsync(messages, options, cancellationToken); + foreach (ChatResponseUpdate update in response.ToChatResponseUpdates()) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs b/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs new file mode 100644 index 00000000..5f9f5548 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Tests.Hooks +{ + /// A logger that throws on every warning, so a dispose step that logs one fails. + internal sealed class WarningThrowingLogger : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + throw new InvalidOperationException("the logger is down"); + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/FacetFilterTests.cs b/tests/AgentCore.Application.Tests/Knowledge/FacetFilterTests.cs index 5755ccd0..35bf495a 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/FacetFilterTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/FacetFilterTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.Domain.Knowledge; using AgentCore.TestSupport; @@ -14,6 +13,7 @@ using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Knowledge { diff --git a/tests/AgentCore.Application.Tests/Knowledge/Fakes/StubKnowledgePort.cs b/tests/AgentCore.Application.Tests/Knowledge/Fakes/StubKnowledgePort.cs index f5ed0137..ee759c7c 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/Fakes/StubKnowledgePort.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/Fakes/StubKnowledgePort.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Domain.Knowledge; +using AgentCore.Application.Runtime.Clarification; namespace AgentCore.Application.Tests.Knowledge.Fakes { @@ -17,11 +17,6 @@ internal sealed class StubKnowledgePort(IReadOnlyList cards) : IK public int Calls { get; private set; } /// Gets the scope the last search was handed, or when none ran. - /// - /// Read here rather than in the test body on purpose: the real store reads the passed scope at - /// exactly this point, deep inside the provider's delegate. A test that reads it anywhere else - /// proves something easier. - /// public KnowledgeScope? ScopeAtTheStore { get; private set; } /// Always : the store is handed a scope, never the turn's holder. @@ -56,19 +51,6 @@ public ValueTask> SearchAsync( /// /// A store that hangs, with a deadline of its own linked into the caller's token. /// - /// - /// - /// This is QdrantKnowledgeStore's cancellation shape, reproduced rather than described: a - /// linked source, CancelAfter, and an await that ends when either side fires. Hand-throwing - /// an instead would let a test pass against a classifier - /// that read the exception's own token — which in the real store is the LINKED token on both paths, - /// and so tells the two cases apart in neither. - /// - /// - /// Whichever side fires, the exception is the same type and carries the same token. Only the - /// caller's own token differs between the two, which is what the provider must read. - /// - /// internal sealed class HangingKnowledgePort(TimeSpan deadline) : IKnowledgeRetrievalPort { private readonly TimeSpan _deadline = deadline; @@ -90,11 +72,6 @@ public async ValueTask> SearchAsync( /// /// A store that filters the way the real one does: it folds the passed scope's facets into its answer. /// - /// - /// ignores the scope, so it can prove which scope arrived but never - /// what the scope costs. This one answers differently under a different scope, which is what makes - /// "an unscoped agent still sees the whole corpus" a fact rather than a restatement of the wiring. - /// internal sealed class ScopeFilteringKnowledgePort( params (KnowledgeCard Card, IReadOnlyDictionary Facets)[] corpus) : IKnowledgeRetrievalPort { diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs index 35701fe7..334af7ad 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs @@ -5,7 +5,6 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Domain.Knowledge; using AgentCore.TestSupport; @@ -13,26 +12,20 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Knowledge { /// - /// §8: the probe. Channel 2 of the ambiguity design — inside the knowledge search itself, dropping + /// The probe: inside the knowledge search itself, dropping /// one wildcard-filled facet, re-searching, and naming the values it finds. /// - /// - /// Every test drives the search tool KnowledgeProviderFactory offers, the same way - /// KnowledgeProviderFactorySearchTests does, rather than through ConversationSession: the probe reads - /// only the turn (, the knowledge scope, the history flag) and the - /// resolved knowledge: block, so filing exactly those by hand proves the same mechanism a - /// real conversation would exercise, at a fraction of the setup. The genuinely two-turn and - /// delegation-shaped cases live in ConversationSessionProbeTests. - /// public sealed class KnowledgeProbeTests { private const string Description = "The model, as printed on the machine."; - // §8 steps 1-2: when the probe does not run at all. + // When the probe does not run at all. [Fact] public async Task Step1_MainSearchReturnedCards_TheProbeNeverRuns() @@ -49,8 +42,8 @@ public async Task Step1_MainSearchReturnedCards_TheProbeNeverRuns() [Fact] public async Task Step2_UnscopedAgentsEmptySearch_ReturnsAnEmptyList() { - // Acceptance: "an unscoped agent's empty search returns an empty list." The probe and the - // "holds nothing" notice are for a scoped agent only (K19). + // "an unscoped agent's empty search returns an empty list." The probe and the + // "holds nothing" notice are for a scoped agent only. ProbeFakePort port = new((_, _) => Task.FromResult>([])); TurnInvocation turn = TurnOf(null, new Clarifications()); @@ -64,7 +57,7 @@ public async Task Step2_UnscopedAgentsEmptySearch_ReturnsAnEmptyList() [Fact] public async Task NoAmbiguityConfigured_StaysByteIdenticalToTheWildcardPlan() { - // K19: with no ambiguity: (and so no wildcard, no template) configured, behaviour is + // With no ambiguity: (and so no wildcard, no template) configured, behaviour is // byte-identical to the wildcard plan's own "holds nothing" notice — no second search. ProbeFakePort port = new((_, _) => Task.FromResult>([])); TurnInvocation turn = TurnOf(FullScope("applies_to", "other"), new Clarifications()); @@ -76,12 +69,12 @@ public async Task NoAmbiguityConfigured_StaysByteIdenticalToTheWildcardPlan() Assert.Equal(1, port.Calls); } - // §8 step 3: which facets are droppable. + // Which facets are droppable. [Fact] public async Task Step3_SingleFacetScope_IsUndroppable() { - // K33: dropping the scope's only facet would open it empty, which a scoped store refuses. + // Dropping the scope's only facet would open it empty, which a scoped store refuses. ProbeFakePort port = new((_, _) => Task.FromResult>([])); TurnInvocation turn = TurnOf(FullScope("applies_to"), new Clarifications()); @@ -141,7 +134,7 @@ public async Task Step4_DropsTheFirstDroppableFacet_InFromStateDeclarationOrder( Assert.Contains(results, r => r.Text.Contains("ct900", StringComparison.Ordinal)); } - // §8 steps 5-6: reading candidates and choosing the message. + // Reading candidates and choosing the message. [Fact] public async Task Steps5And6_FindsCandidates_RecordsWhatItNamedAndEmitsTheNote() @@ -170,7 +163,7 @@ public async Task Steps5And6_FindsCandidates_RecordsWhatItNamedAndEmitsTheNote() [Fact] public async Task Step5_ReadsAListShapedFacetValue_NotJustAString() { - // K24's own example: a card tagged applies_to: ["ct900", "ct900ent"]. A cast straight to + // Example: a card tagged applies_to: ["ct900", "ct900ent"]. A cast straight to // string would return null for every card on a multi-model collection. ProbeFakePort port = new((facets, _) => Task.FromResult>( facets.ContainsKey("applies_to") @@ -223,7 +216,7 @@ public async Task K6_TheWildcardValueItselfIsDiscardedFromTheCandidates() [Fact] public async Task K24_OneCardIsNeverASpread() { - // Acceptance: "one card is never a spread." One value produces a confirm question, not + // "one card is never a spread." One value produces a confirm question, not // "fewer than two -> holds nothing". ProbeFakePort port = new((facets, _) => Task.FromResult>( facets.ContainsKey("applies_to") ? [] : [CardWithFacet("a", "applies_to", "ct900")])); @@ -242,7 +235,7 @@ public async Task K24_OneCardIsNeverASpread() [Fact] public async Task MoreThanMaxCandidates_NamesNone() { - // Acceptance: "more than maxCandidates names none." The probe still speaks — this is not the + // "more than maxCandidates names none." The probe still speaks — this is not the // "holds nothing" notice — but the rendered text omits every value. string[] many = [.. Enumerable.Range(0, 7).Select(i => $"model{i}")]; ProbeFakePort port = new((facets, _) => Task.FromResult>( @@ -298,7 +291,7 @@ public async Task ZeroCandidatesFound_EmitsHoldsNothing() Assert.Contains("holds nothing", Assert.Single(results).Text, StringComparison.Ordinal); } - // K39: a graph document's probe still speaks, but must not record what it named. + // A graph document's probe still speaks, but must not record what it named. [Fact] public async Task K39_AGraphDocumentsProbe_EmitsItsNote_AndRecordsNoLastNamed() @@ -313,12 +306,12 @@ public async Task K39_AGraphDocumentsProbe_EmitsItsNote_AndRecordsNoLastNamed() Assert.Contains("ct900", Assert.Single(results).Text, StringComparison.Ordinal); - // The record of what was named is withheld: K21's tie-break rests on the caller having heard + // The record of what was named is withheld: the tie-break rests on the caller having heard // the list, which AgentCore cannot know on a graph row. Assert.Equal(Clarifications.LastNamedKind.None, clarificationsObject.Read("applies_to").LastNamed.Kind); } - // K42: no holder, no probe -- but the notice is still owed to a scoped run. + // No holder, no probe -- but the notice is still owed to a scoped run. [Fact] public async Task K42_NoHolderOnTheTurn_TheProbeDoesNotSearch_ButStillEmitsTheNotice() @@ -326,7 +319,7 @@ public async Task K42_NoHolderOnTheTurn_TheProbeDoesNotSearch_ButStillEmitsTheNo ProbeFakePort port = new((_, _) => Task.FromResult>([])); TurnInvocation turn = TurnOf(FullScope("applies_to", "other"), null); - // No Clarifications on the turn at all -- the K42 strip inside a nested tool call. + // No Clarifications on the turn at all -- the strip inside a nested tool call. IReadOnlyList results = await InvokeSearchAsync(Provider(port, Resolved(["applies_to"])), "e33", turn); Assert.Contains("holds nothing", Assert.Single(results).Text, StringComparison.Ordinal); @@ -336,7 +329,7 @@ public async Task K42_NoHolderOnTheTurn_TheProbeDoesNotSearch_ButStillEmitsTheNo [Fact] public async Task K42_ASubAgentThatSearchesFirst_WritesNoLastNamed_AndTheCallersOwnSearchStillCounts() { - // Acceptance: "a sub-agent that searches first writes no lastNamed, and the caller's own + // "a sub-agent that searches first writes no lastNamed, and the caller's own // search still counts." Simulates exactly what AuditingFunctionInvokingChatClient. // InvokeFunctionAsync does for a NESTED tool call: it strips Clarifications from the turn, // leaving the outer call id in place. @@ -361,7 +354,7 @@ public async Task K42_ASubAgentThatSearchesFirst_WritesNoLastNamed_AndTheCallers Assert.Equal(1, clarificationsObject.Read("applies_to").ProbeAsks); } - // K25: every notice this design emits carries the reserved source name, and none is citable. + // Every notice the probe emits carries the reserved source name, and none is citable. [Fact] public async Task EveryOutcome_CarriesTheReservedSourceName() @@ -381,7 +374,7 @@ public async Task EveryOutcome_CarriesTheReservedSourceName() [Fact] public async Task K23_AProbeSpread_PublishesNoSources() { - // Acceptance: "a probe spread publishes no sources." The probe runs between Kept and Cite, so + // "a probe spread publishes no sources." The probe runs between Kept and Cite, so // nothing it discards -- and nothing it names -- is ever published as a citable source. ProbeFakePort port = new((facets, _) => Task.FromResult>( facets.ContainsKey("applies_to") @@ -391,16 +384,13 @@ public async Task K23_AProbeSpread_PublishesNoSources() AIContextProvider provider = Provider(port, Resolved(["applies_to"], citations: true)); TurnSources sources = new(); - TurnInvocation turn = TurnOf(FullScope("applies_to", "other"), new Clarifications(), sources: sources); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await InvokeSearchAsync(provider, "e33", turn); - } + TurnInvocation turn = TurnOf(FullScope("applies_to", "other"), new Clarifications(), sources: sources) with { OuterCallId = "conversation-1" }; + _ = await InvokeSearchAsync(provider, "e33", turn); Assert.Empty(sources.TakeFor("conversation-1")); } - // K32: the probe's own try, its own budget, its own log events. + // The probe's own try, its own budget, its own log events. [Fact] public async Task Probe_SecondSearchThrows_EmitsHoldsNothing_AndLogsItsOwnFailureEvent() @@ -455,12 +445,12 @@ public async Task Probe_SecondSearchTimesOut_IsTreatedAsAFailure_NeverAsUnreacha Assert.Contains("holds nothing", Assert.Single(results).Text, StringComparison.Ordinal); - // §8 step 4's increment ran before the search; a timeout that is not caller cancellation - // never rolls it back the way K43's own cancellation row does. + // The increment ran before the search; a timeout that is not caller cancellation + // never rolls it back the way the cancellation row does. Assert.Equal(1, clarificationsObject.Read("applies_to").ProbeAsks); } - // K43: at most once per turn -- the latch, the replay, and cancellation. + // At most once per turn -- the latch, the replay, and cancellation. [Fact] public async Task K43_ThreeSearchConversationsInOneTurn_RunOneProbe_AndIncrementOnce() @@ -639,7 +629,7 @@ public async Task K43_ACancelledProbe_WakesAGenuinelyWaitingLoser_RatherThanLett [Fact] public async Task K43_ProbeThatThrowsEveryTurn_StillAdvancesProbeAsks() { - // Acceptance: "a probe that throws every turn still advances probeAsks." Two turns, + // "a probe that throws every turn still advances probeAsks." Two turns, // simulated with the same BeginTurn a real ConversationSession conversations: the latch and the per-turn mark // reset, the counter does not. int attempts = 0; @@ -672,11 +662,10 @@ public async Task K43_ProbeThatThrowsEveryTurn_StillAdvancesProbeAsks() [Fact] public async Task Loser_WaitExpiresBeforeAnyOutcomeIsPublished_EmitsHoldsNothing_NeverUnreachable() { - // C1's own gap: the probe is claimed directly, exactly as a real winner's ProbeAsync would, + // The probe is claimed directly, exactly as a real winner's ProbeAsync would, // but its payload is never published -- neither Publish nor Fail. With ProbeDeadlineSeconds // and ProbeWaitMarginSeconds both 0, the loser's own wait times out almost at once, which is - // the row the fix exists for: - // a TimeoutException reaching SearchAsync's outer catch would blame the main search, which + // the case under test: a TimeoutException reaching SearchAsync's outer catch would blame the main search, which // never ran a second time and never failed. ProbeFakePort port = new((_, _) => Task.FromResult>([])); @@ -791,7 +780,7 @@ public async Task Step4_TheNarrowedScopeKeepsTheCallersOwnComparer() [Fact] public async Task Step4_TheDeadlineFiredBeforeTheCallerHungUp_KeepsTheAskCharged() { - // K22's cap is only monotone if a timeout is charged. Classifying by "is the caller's token + // The cap is only monotone if a timeout is charged. Classifying by "is the caller's token // cancelled now" would refund one whenever the caller happens to hang up in the moment after // the deadline fired, and a probe that always times out could then offer the same facet // forever. The port below makes that order certain: it waits for its own token to be @@ -1152,7 +1141,7 @@ private static KnowledgeCard CardWithFacetList(string id, string facetKey, IRead /// /// The cancellation row's own entry point: here is the - /// SIMULATED CALLER's own token (K43's cancellation row), never the test host's — see + /// SIMULATED CALLER's own token (the cancellation row), never the test host's — see /// , its /// only caller. Named apart from /// so a deliberately non-TestContext token at this one conversation site does not read as a mistake. diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs index 97804783..daca742b 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs @@ -45,22 +45,18 @@ public async Task Create_CitationsOn_NamesTheSourceAndForbidsAnInventedLink() [Fact] public async Task Create_CitationsOn_PublishesASourceForEachCardShown() { - // Task 5 fix round 1, Finding 1. Nothing in this suite drove the sources port to a - // non-null value for a citing search before this test, so the body of the publish loop had - // never executed. A collector plus an outer call, both open around the same InvokingAsync - // the other tests already drive, is what proves the wiring rather than just reading it. + // This drives the sources port to a non-null value for a citing search, + // which runs the body of the publish loop. A collector plus an outer call, both on the turn the same InvokingAsync + // the other tests already drive reads, is what proves the wiring rather than just reading it. StubKnowledgePort port = new([Card("a"), Card("b")]); AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, citations: true)); TurnSources sources = new(); StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn(sources: sources)); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - } + TurnRegistry.Set(session, PrefetchTurn(sources: sources) with { OuterCallId = "conversation-1" }); + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); IReadOnlyList cited = sources.TakeFor("conversation-1"); Assert.Equal(2, cited.Count); @@ -75,19 +71,16 @@ public async Task Create_CitationsOff_PublishesNothing() // The leak test, and the most important one in this task. citations: false is the single // switch a deployment uses to decide whether it discloses its sources at all -- a chip on // screen is a disclosure just as much as the label the model is shown -- so this must - // publish nothing even with a collector and an outer call both open and cards coming back. + // publish nothing even with a collector and an outer call both on the turn and cards coming back. StubKnowledgePort port = new([Card("a"), Card("b")]); AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, citations: false)); TurnSources sources = new(); StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn(sources: sources)); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - } + TurnRegistry.Set(session, PrefetchTurn(sources: sources) with { OuterCallId = "conversation-1" }); + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); Assert.Empty(sources.TakeFor("conversation-1")); } diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs index 6cb2e309..95673c31 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs @@ -17,7 +17,7 @@ public sealed class KnowledgeProviderFactoryLimitTests public async Task Create_MoreCardsThanTheAgentAsksFor_KeepsTheBestFew( int limit, string lastKept, string firstDropped) { - // Ruling 14(c). The store fetches once, up to its own deployment ceiling, for every agent. + // The store fetches once, up to its own deployment ceiling, for every agent. // limit: is this agent's view of that fetch, and the port returns cards best first. // Two limits, because one cannot tell the agent's limit from a hardcoded number. StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Card("d"), Card("e")]); @@ -37,7 +37,7 @@ public async Task Create_MoreCardsThanTheAgentAsksFor_KeepsTheBestFew( [Fact] public async Task Create_TheLimit_CountsRankedCardsAndLetsALinkedOneRideAlong() { - // Ruling 16. A7 makes see_also expansion never optional, and the store appends the linked + // see_also expansion is never optional, and the store appends the linked // cards after every scored one. A plain prefix would drop every link whenever the fetch // filled up -- which, at the shipped defaults of 5 and 5, is every full result. The winning // probe arm was "top 5 plus see_also of the top hit": the links are additional to the five. @@ -58,7 +58,7 @@ public async Task Create_TheLimit_CountsRankedCardsAndLetsALinkedOneRideAlong() [Fact] public async Task Create_MoreCardsThanTheLimit_CitesOnlyTheCardsTheModelIsShown() { - // Finding 2. A card ranked past the agent's limit: is never shown to the model, so citing + // A card ranked past the agent's limit: is never shown to the model, so citing // it anyway would put a chip on screen for a document the answer could not possibly have // drawn on. The cited set must match the kept set, not the store's full return. StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Card("d"), Card("e")]); @@ -67,12 +67,9 @@ public async Task Create_MoreCardsThanTheLimit_CitesOnlyTheCardsTheModelIsShown( TurnSources sources = new(); StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn(sources: sources)); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - } + TurnRegistry.Set(session, PrefetchTurn(sources: sources) with { OuterCallId = "conversation-1" }); + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); IReadOnlyList cited = sources.TakeFor("conversation-1"); Assert.Equal(2, cited.Count); diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs index 32a2e69f..e7e0e72a 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs @@ -1,11 +1,11 @@ using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Clarification; using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; namespace AgentCore.Application.Tests.Knowledge diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs index 6f2f2c91..76d34402 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Knowledge; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs index 2d34a7ff..9864241c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs @@ -10,13 +10,14 @@ using Xunit; using static AgentCore.Application.Tests.Runtime.AgentCoreAgentTestSupport; using Microsoft.Agents.AI; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { /// /// The shim's session envelope: what /// SerializeSessionAsync/DeserializeSessionAsync carry across a checkpoint, and what a - /// resumed conversation keeps of store 0's own record. + /// resumed conversation keeps of the conversation store's own record. /// public sealed class AgentCoreAgentSessionTests { @@ -51,7 +52,7 @@ public async Task SerializeSessionAsync_NamesTheConversationTheStateBelongsTo() JsonElement serialized = await agent.SerializeSessionAsync( session, cancellationToken: TestContext.Current.CancellationToken); - // Store 1 is keyed by conversation id. State that travelled without one would come back on a conversation + // The message store is keyed by conversation id. State that travelled without one would come back on a conversation // that has no words behind it, which is the one failure this envelope exists to prevent. Assert.Equal("conversation-42", serialized.GetProperty("conversationId").GetString()); } @@ -82,7 +83,7 @@ public async Task SerializeSessionAsync_AfterItsConversationHasUnloaded_WritesTh JsonElement serialized = await agent.SerializeSessionAsync( session, cancellationToken: TestContext.Current.CancellationToken); - // Store 0's own copy of the state outranks whatever this session held before it unloaded, so + // The conversation store's own copy of the state outranks whatever this session held before it unloaded, so // the blob carries only the id: a live session's snapshot would be stale the moment it wrote. Assert.Equal("conversation-42", serialized.GetProperty("conversationId").GetString()); Assert.Equal(JsonValueKind.Null, serialized.GetProperty("state").ValueKind); @@ -164,8 +165,8 @@ public async Task ARoundTripBeforeTheFirstTurn_LosesNothing() // And the ordinary path is untouched. Once the turn opens the conversation, the document is what // the conversation would resume from and the checkpoint is spent — so a value written after that - // turn is what comes back, not the one the checkpoint still holds. Store 1's writer reads - // this same method, so a snapshot that kept answering the checkpoint would freeze store 0 + // turn is what comes back, not the one the checkpoint still holds. The message store's writer reads + // this same method, so a snapshot that kept answering the checkpoint would freeze the conversation store // at the moment the conversation was revived and never record another thing the conversation learned. _ = await agent.RunAsync("hello", revived, cancellationToken: TestContext.Current.CancellationToken); @@ -234,7 +235,7 @@ public async Task SerializeSessionAsync_CarriesNoWords() // scalars, and an implementation that wrote {} would pass the two assertions below. Assert.Contains("escalate", raw, StringComparison.Ordinal); - // Store 1 is durable already. A checkpoint that carried the words would give one conversation + // The message store is durable already. A checkpoint that carried the words would give one conversation // two records and one chance to disagree. Assert.DoesNotContain("remember this sentence", raw, StringComparison.Ordinal); Assert.DoesNotContain("a reply", raw, StringComparison.Ordinal); @@ -246,7 +247,7 @@ public async Task DeserializeSessionAsync_WhenStore0KnowsTheConversation_Store0W InMemoryConversationStore store = new(); _ = await store.CreateAsync("conversation-42", TestContext.Current.CancellationToken); - // Store 0's own copy of this conversation: one word, and the state written in the same batch. + // The conversation store's own copy of this conversation: one word, and the state written in the same batch. _ = await store.AppendAsync( "conversation-42", [new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "an earlier turn"), "m0")], @@ -261,8 +262,8 @@ public async Task DeserializeSessionAsync_WhenStore0KnowsTheConversation_Store0W AgentCoreAgent agent = BuildAgent(new SequencedChatClient("a reply"), out _, SlottedAgentYaml, store); - // It disagrees with store 0 on one slot and carries a second store 0 knows nothing about. - // The second is what a merge would leak: store 0 has no value to write over it. + // It disagrees with the conversation store on one slot and carries a second slot the conversation store knows nothing about. + // The second is what a merge would leak: the conversation store has no value to write over it. ConversationSessionState checkpoint = new() { Slots = new Dictionary(StringComparer.Ordinal) @@ -280,13 +281,13 @@ public async Task DeserializeSessionAsync_WhenStore0KnowsTheConversation_Store0W ConversationSession? conversation = revived.GetService(); Assert.NotNull(conversation); - // False, which is store 0's value and not the checkpoint's. Store 0's blob rides the same - // batch as the words above, so its state and store 1's words are of one moment; a + // False, which is the conversation store's value and not the checkpoint's. The conversation store's blob rides the same + // batch as the words above, so its state and the message store's words are of one moment; a // checkpoint's state beside those same words can be of two. One precedence, stated. Assert.False(conversation.State.Read("escalate")!.GetValue()); - // And store 0 wins outright rather than per slot. Where store 0 holds state, the checkpoint - // contributes nothing at all — not even the slot store 0 never heard of. + // And the conversation store wins outright rather than per slot. Where the conversation store holds state, the checkpoint + // contributes nothing at all — not even the slot the conversation store never heard of. Assert.True(conversation.State.IsUnfilled("note")); } @@ -295,7 +296,7 @@ public async Task DeserializeSessionAsync_WithNeitherMember_Throws() { AgentCoreAgent agent = BuildAgent(new SequencedChatClient("unused"), out _, SlottedAgentYaml); - // The bare ConversationSessionState: the literal value store 0 keeps in conversation.state, and the other + // The bare ConversationSessionState: the literal value the conversation store keeps in conversation.state, and the other // of the two shapes in this system. Read as an envelope it names no conversation, and a new random // id would take the conversation's whole transcript with it. JsonElement bare = JsonSerializer.SerializeToElement( @@ -357,8 +358,7 @@ public async Task ARoundTripOfATerminalConversation_ComesBackTerminalAndRefusesI AgentSession revived = await agent.DeserializeSessionAsync( serialized, cancellationToken: TestContext.Current.CancellationToken); - // A behaviour change worth pinning rather than inferring: before the conversation state blob, a - // reloaded page met a conversation that had forgotten it ended and answered one more turn. + // A reloaded page must meet a conversation that remembers it ended, not answer one more turn. InvalidOperationException failure = await Assert.ThrowsAsync( () => agent.RunAsync("again", revived, cancellationToken: TestContext.Current.CancellationToken)); @@ -378,7 +378,7 @@ public async Task Resume_AfterTheConversationHasOpened_ThrowsRatherThanDoingNoth // The state is read as the session opens and never again, so a late hand-off would be a // silent no-op. Saying so is the whole reason the guard is here. - InvalidOperationException failure = Assert.Throws(() => conversation.Resume(new ConversationSessionState())); + InvalidOperationException failure = Assert.Throws(() => conversation.States.Resume(new ConversationSessionState())); Assert.Contains("already run a turn", failure.Message, StringComparison.Ordinal); } diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs index 8b4f6216..bbc01e31 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs @@ -2,6 +2,7 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; @@ -10,6 +11,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { @@ -74,17 +76,17 @@ internal static AgentCoreAgent BuildAgent( string yaml = SingleAgentYaml, IConversationStore? store = null, string entryName = "main", - IEnumerable? observers = null, TimeProvider? timeProvider = null, - TimeSpan? idleTimeout = null) + TimeSpan? idleTimeout = null, + IReadOnlyList? hooks = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); IReadOnlyDictionary entries = ConfigurationCompiler.CompileAll( document, - new AgentCompilationContext(new RoutingChatClientFactory(reply)) { ConversationStore = store }); + new AgentCompilationContext(new RoutingChatClientFactory(reply)) { ConversationStore = store, Hooks = hooks }); compiled = entries[entryName]; - ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards), observers: observers); + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); InMemoryConversationSessions sessions = new( new Dictionary(StringComparer.Ordinal) { [entryName] = factory }, idleTimeout ?? TimeSpan.FromMinutes(30), diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs index 3bc7c369..47dba8ff 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs @@ -1,11 +1,13 @@ using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Hooks; using AgentCore.Application.Runtime; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.AgentCoreAgentTestSupport; namespace AgentCore.Application.Tests.Runtime @@ -100,9 +102,11 @@ public async Task RunAsync_WithNoSession_RunsANormalTurnAndLeavesTheConversation { SequencedChatClient reply = new("one", "two"); InMemoryAuditSink sink = new(); - AgentCoreAgent agent = BuildAgent(reply, out _, observers: ConversationObservers.Standard(sink, logger: null)); + AgentCoreAgent agent = BuildAgent( + reply, out CompiledAgent compiled, hooks: BuiltInHooks.Create(sink)); AgentResponse first = await agent.RunAsync("first", cancellationToken: TestContext.Current.CancellationToken); + await compiled.Hooks.Notices.FlushAllAsync(); Assert.Equal("one", first.Text); AuditEvent started = Assert.Single(sink.Events, e => e.Kind == AuditEventKind.ConversationStarted); @@ -138,7 +142,7 @@ public async Task RunAsync_OnAHeldSessionAfterItsConversationUnloaded_StillRuns( Assert.Equal("second reply", second.Text); - // History survived the unload: the rebuilt session re-read the transcript store 0 still holds. + // History survived the unload: the rebuilt session re-read the transcript the conversation store still holds. Assert.Contains(reply.Requests[1], message => message.Role == ChatRole.User && message.Text == "hello"); Assert.Contains(reply.Requests[1], message => message.Role == ChatRole.Assistant && message.Text == "first reply"); } diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs index ae8e0878..c09bac0f 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs @@ -2,20 +2,19 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { /// - /// Bug 7 of the 2026-09-24 round: DelegatedAgentRun made a fresh session per kind: agent - /// call and never released it, so a background: child the delegate started outlived the - /// conversation. The delegate's own ChatOptions attaches its - /// directly, so the fresh session's only owner is the delegation call itself — it must release it when - /// the tool returns, on the happy path and on a fault. + /// A delegation makes a fresh session per kind: agent call, so a background: child the + /// delegate starts must not outlive the conversation. The delegate's own ChatOptions attaches its + /// directly, so the fresh session's only owner is + /// the delegation call itself — it must release the session when the tool returns, on the happy path and on a fault. /// public sealed class AgentDelegationBackgroundReleaseTests { @@ -66,10 +65,10 @@ public async Task Delegation_ToAnAgentWithABackgroundChild_ReleasesItWhenTheTool } /// - /// Claim (R4-5): a delegation whose fresh session started a background child that ignores its cancel - /// must not stall the outer turn for 's timeout. Before - /// the fix, the delegation's finally awaited the release with - /// inside the tool call, so the outer model never saw the tool result until the release gave up. + /// A delegation whose fresh session started a background child that ignores its cancel + /// must not stall the outer turn for 's timeout. The release + /// must not be awaited with inside the tool call, or the outer model + /// would not see the tool result until the release gave up. /// [Fact] public async Task Delegation_ToAnAgentWithADeafChild_DoesNotStallTheTurn() diff --git a/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientCallRecordTests.cs b/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientCallRecordTests.cs index 41840050..beb34f80 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientCallRecordTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientCallRecordTests.cs @@ -1,8 +1,9 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools.Binding; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientErrorPolicyTests.cs b/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientErrorPolicyTests.cs index f8797280..1f79a7ce 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientErrorPolicyTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientErrorPolicyTests.cs @@ -2,26 +2,21 @@ using AgentCore.Application.Tools.Registry; using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Runtime { /// - /// Task 7a: the tool error policy moved out of and into - /// , the framework's single choke - /// point for every tool call. These tests pin the caller-observable behaviour that move must not - /// change, and prove the reason for the move: a plain AIFunctionFactory.Create(...) tool, which - /// is not a at all, now gets identical treatment. + /// The tool error policy lives in , the + /// framework's single choke point for every tool call. These tests pin its caller-observable behaviour, including + /// that a plain AIFunctionFactory.Create(...) tool, which is not a at all, + /// gets identical treatment. /// - /// - /// Every test drives directly against a fake inner - /// , with no YAML document and no ConversationSession, so a failure here - /// isolates the seam itself rather than the turn loop built on top of it. - /// public sealed class AuditingFunctionInvokingChatClientErrorPolicyTests { private static readonly ToolConfiguration LookupOrder = new() @@ -50,26 +45,8 @@ public async Task AFaultTheModelCanAnswer_BecomesTheErrorResultTheModelReads() StringComparison.Ordinal); } - // --------------------------------------------------------------------------------------- - // 4. Reporting must not change: an answerable fault never leaves the tool as an exception, - // so it must never reach the turn's failure listener. - // --------------------------------------------------------------------------------------- - [Fact] - public async Task AFaultTheModelCanAnswer_IsNeverReported() - { - List reported = []; - TurnInvocation turn = new() { ConversationId = "conversation", TurnIndex = 0, Stage = "", OnToolFailure = reported.Add }; - - ThrowingDeclaredTool tool = new(LookupOrder, new InvalidOperationException("the order is already closed.")); - _ = await RunSingleRoundAsync(tool, TestContext.Current.CancellationToken, turn: turn); - - Assert.Empty(reported); - } - - // --------------------------------------------------------------------------------------- - // 2. A fault the model CANNOT answer still propagates, so the framework's own consecutive- + // A fault the model cannot answer still propagates, so the framework's own consecutive- // error budget (MaximumConsecutiveErrorsPerRequest = 3) counts it and the 4th round throws. - // --------------------------------------------------------------------------------------- [Fact] public async Task AFaultTheModelCannotAnswer_PropagatesAndSpendsTheConsecutiveErrorBudget() { @@ -101,34 +78,14 @@ public async Task AFaultTheModelCannotAnswer_KeepsItsOriginalStack() Assert.Contains("CallAsync", thrown.StackTrace, StringComparison.Ordinal); } - // --------------------------------------------------------------------------------------- - // 4 (other half). Reporting must not change: a propagating fault is still reported once for - // every round it spends of the budget, exactly as it is today. - // --------------------------------------------------------------------------------------- - [Fact] - public async Task AFaultTheModelCannotAnswer_IsReportedOnceForEveryPropagatingRound() - { - List reported = []; - TurnInvocation turn = new() { ConversationId = "conversation", TurnIndex = 0, Stage = "", OnToolFailure = reported.Add }; - - TimeoutException failure = new("the endpoint did not answer."); - ThrowingDeclaredTool tool = new(LookupOrder, failure); - _ = await RunUntilTheBudgetThrowsAsync(tool, TestContext.Current.CancellationToken, turn); - - // MaximumConsecutiveErrorsPerRequest is 3, so the 4th round is the one that spends the - // budget, and all four reached this middleware and were reported before they propagated. - Assert.Equal(4, reported.Count); - } - // --------------------------------------------------------------------------------------- // 1. Caller cancellation passes through untouched. The TOKEN decides, never the exception - // type, and it is never reported. + // type. // --------------------------------------------------------------------------------------- [Fact] - public async Task ACallerThatHungUp_PassesTheCancellationThroughUnreported() + public async Task ACallerThatHungUp_PassesTheCancellationThrough() { - List reported = []; - TurnInvocation turn = new() { ConversationId = "conversation", TurnIndex = 0, Stage = "", OnToolFailure = reported.Add }; + TurnInvocation turn = new() { ConversationId = "conversation", TurnIndex = 0, Stage = "" }; using CancellationTokenSource source = new(); // The tool cancels the very token the call was made with and then throws, exactly as a @@ -147,16 +104,11 @@ public async Task ACallerThatHungUp_PassesTheCancellationThroughUnreported() [new ChatMessage(ChatRole.User, "where is my order")], options, source.Token)); - - Assert.Empty(reported); } - // --------------------------------------------------------------------------------------- - // THE UNLOCK: a plain AIFunctionFactory.Create(...) tool is not a DeclaredTool at all, so - // before Task 7a moved the policy into this middleware, nothing classified its faults — see - // ThrowingToolBuilder in RuntimeFakes.cs, which throws straight at the framework today. After - // the move it gets the identical answerable-fault treatment a DeclaredTool gets. - // --------------------------------------------------------------------------------------- + // A plain AIFunctionFactory.Create(...) tool is not a DeclaredTool, yet its faults are classified + // like a DeclaredTool's: it gets the identical answerable-fault treatment. See ThrowingToolBuilder in + // RuntimeFakes.cs, which throws straight at the framework. [Fact] public async Task APlainAIFunctionFactoryTool_GetsTheSameErrorResultAsADeclaredTool() { @@ -212,16 +164,11 @@ public async Task ARealBindingTool_GetsTheSameErrorResultThroughTheRealMiddlewar private static async Task RunSingleRoundAsync( AIFunction tool, CancellationToken cancellationToken, - Dictionary? arguments = null, - TurnInvocation? turn = null) + Dictionary? arguments = null) { ToolCallingChatClient inner = new("the loop continues.", arguments); using AuditingFunctionInvokingChatClient client = new(inner); ChatOptions options = new() { Tools = [tool] }; - if (turn is not null) - { - options.AdditionalProperties = new AdditionalPropertiesDictionary { [TurnInvocation.ArgumentsKey] = turn }; - } _ = await client.GetResponseAsync( [new ChatMessage(ChatRole.User, "where is my order")], @@ -236,19 +183,11 @@ [new ChatMessage(ChatRole.User, "where is my order")], /// Runs rounds until the framework's own MaximumConsecutiveErrorsPerRequest budget throws, /// and returns what it threw. /// - /// - /// never stops calling the tool, so a fault that - /// propagates on every round spends the budget on the fourth. - /// - private static async Task RunUntilTheBudgetThrowsAsync(AIFunction tool, CancellationToken cancellationToken, TurnInvocation? turn = null) + private static async Task RunUntilTheBudgetThrowsAsync(AIFunction tool, CancellationToken cancellationToken) { LoopingToolCallingChatClient inner = new(); using AuditingFunctionInvokingChatClient client = new(inner); ChatOptions options = new() { Tools = [tool] }; - if (turn is not null) - { - options.AdditionalProperties = new AdditionalPropertiesDictionary { [TurnInvocation.ArgumentsKey] = turn }; - } return await Assert.ThrowsAnyAsync(() => client.GetResponseAsync( diff --git a/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientStripTests.cs b/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientStripTests.cs index fa0a6e0a..6a550596 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientStripTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AuditingFunctionInvokingChatClientStripTests.cs @@ -1,28 +1,22 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tools.Binding; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Knowledge.Fakes; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Runtime { /// - /// K42's strip: hands a - /// nested tool call the turn with its stripped, and hands the - /// outermost call the whole turn. + /// hands a nested tool call the turn + /// with its stripped, and hands the outermost call the whole turn. /// - /// - /// The first two facts drive directly, the same way - /// does, with the turn filed - /// on the conversation's own options. The third drives a real turn, - /// because that is the only place that proves the session actually threads its - /// own Clarifications instance through — a fact the first two cannot see, since they file the - /// turn by hand. - /// public sealed class AuditingFunctionInvokingChatClientStripTests { [Fact] @@ -65,7 +59,7 @@ public async Task ANestedToolCall_HasNoHolder_WhileTheOuterCallStillDoes() { // The outer tool's own invocation reads the filed turn before starting the nested loop, and the // inner tool reads it from inside that nested loop's own InvokeFunctionAsync. The two reads - // must disagree: K42's strip is conditional on being nested, not on being any tool call at + // must disagree: the strip is conditional on being nested, not on being any tool call at // all, or it would blind the caller's own search too. Clarifications clarifications = new(); Clarifications? seenOutside = null; @@ -139,11 +133,9 @@ private static TurnInvocation TurnOf(Clarifications clarifications) }; } - // ------------------------------------------------------------------------------------------- - // The wiring fact: a real ConversationSession turn opens its own Clarifications and files it on the + // A real ConversationSession turn opens its own Clarifications and files it on the // run, so the probe — not the store — sees it. The increment below proves both halves: no // holder, or no filing, and the probe's search never runs. - // ------------------------------------------------------------------------------------------- [Fact] public async Task RunTurnAsync_AToolModeKnowledgeSearch_SeesTheHolder() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ClarificationTextTests.cs b/tests/AgentCore.Application.Tests/Runtime/ClarificationTextTests.cs index 3a7d42b9..94d07d0a 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ClarificationTextTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ClarificationTextTests.cs @@ -1,15 +1,14 @@ -using AgentCore.Application.Runtime; using Xunit; +using AgentCore.Application.Runtime.Clarification; namespace AgentCore.Application.Tests.Runtime { /// - /// The probe's note (§8), rendered against §4's own description: values so a human can read + /// The probe's note, rendered against real description: values so a human can read /// the result as English rather than a template. /// public sealed class ClarificationTextTests { - // §4's own value, verbatim: "The model, as printed on the machine." private const string AppliesToDescription = "The model, as printed on the machine."; [Fact] diff --git a/tests/AgentCore.Application.Tests/Runtime/ClarificationsTests.cs b/tests/AgentCore.Application.Tests/Runtime/ClarificationsTests.cs index d74985c6..2c2732a9 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ClarificationsTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ClarificationsTests.cs @@ -1,20 +1,18 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Xunit; +using AgentCore.Application.Runtime.Clarification; namespace AgentCore.Application.Tests.Runtime { /// - /// : the per-conversation holder K36 describes, K41's per-turn mark, and K43's + /// : the per-conversation holder, the per-turn mark, and the /// probe latch and replay payload. /// public sealed class ClarificationsTests { - // ----------------------------------------------------------------------------------------- - // K43: the probe latch is a compare-and-set. Several search-tool calls in one turn, or several + // The probe latch is a compare-and-set. Several search-tool calls in one turn, or several // graph participants sharing this conversation, must cost exactly one probe. - // ----------------------------------------------------------------------------------------- [Fact] public void ClaimProbe_ManyConcurrentCallers_ExactlyOneClaimsEachTrial() { @@ -69,10 +67,8 @@ public void ClaimProbe_ManyConcurrentCallers_ExactlyOneClaimsEachTrial() } } - // ----------------------------------------------------------------------------------------- - // K43: the caller hanging up mid-probe must fail the payload, not merely drop it, so every + // The caller hanging up mid-probe must fail the payload, not merely drop it, so every // loser waiting on it wakes rather than burning its full wait margin on a corpse. - // ----------------------------------------------------------------------------------------- [Fact] public async Task Fail_WakesEveryWaiter_AndLeavesTheLatchClaimed() { @@ -121,10 +117,8 @@ public async Task Publish_LetsEveryWaiterReplayTheSameCards() Assert.All(results, result => Assert.Same(cards, result)); } - // ----------------------------------------------------------------------------------------- - // K43: BeginTurn opens a fresh turn. It clears the probe latch and its payload together, and + // BeginTurn opens a fresh turn. It clears the probe latch and its payload together, and // never the counter — that is per-conversation, not per-turn. - // ----------------------------------------------------------------------------------------- [Fact] public void BeginTurn_ClearsTheLatch_AndLeavesTheCounter() { @@ -161,10 +155,8 @@ public async Task BeginTurn_ThePayloadFromTheLastTurn_IsGoneAndNotJustUnclaimed( () => loser.WaitAsync(TimeSpan.FromMilliseconds(1), TestContext.Current.CancellationToken)); } - // ----------------------------------------------------------------------------------------- // A search that outlives its turn holds turn N's handle. Publishing through it must not resolve // the latch turn N+1 opened, or every loser of the new turn replays the old turn's cards. - // ----------------------------------------------------------------------------------------- [Fact] public async Task Publish_FromTheTurnBefore_LeavesTheNewTurnsLatchAlone() { @@ -220,10 +212,8 @@ public void Spent_LeavesOutASlotNothingHasAskedAbout() Assert.Empty(clarifications.Spent()); } - // ----------------------------------------------------------------------------------------- // Edit-and-resend: Withdraw clears what was named, and leaves the ask counter untouched so a // withdrawn segment cannot buy a fresh maxAsks budget. - // ----------------------------------------------------------------------------------------- [Fact] public void Withdraw_ClearsLastNamed_AndLeavesTheCounter() { @@ -263,12 +253,10 @@ public void Update_TheSameName_SharesOneUnderlyingState() Assert.Equal(7, clarifications.Read("brand").ProbeAsks); } - // ----------------------------------------------------------------------------------------- - // §8's compound transitions (K36): a slot's fields must move together as one step, or a - // concurrent reader can catch it between the two writes. This is the §8-step-6 shape - record - // what was named and move a counter - reduced to its atomicity core: LastNamed is set exactly - // when ProbeAsks is odd, and a reader that ever sees them disagree caught a torn write. - // ----------------------------------------------------------------------------------------- + // A slot's fields must move together as one step, or a concurrent reader can catch it between the + // two writes. This is the record-what-was-named-and-move-a-counter shape, reduced to its atomicity + // core: LastNamed is set exactly when ProbeAsks is odd, and a reader that ever sees them disagree + // caught a torn write. [Fact] public async Task Update_ACompoundTransition_IsNeverObservedHalfApplied() { diff --git a/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs b/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs index d9c69605..1e24dfcd 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs @@ -1,17 +1,17 @@ using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Transcript; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; namespace AgentCore.Application.Tests.Runtime.Compaction { /// - /// D6: the streaming merge surfaces a compaction notice while the summariser's own model call is + /// The streaming merge surfaces a compaction notice while the summariser's own model call is /// still in flight, in order, and a cancel mid-wait ends the stream rather than hanging it. The /// summariser is gated rather than delayed, so "the notice arrived before the summariser finished" /// is a fact the test controls directly instead of a race against a clock. diff --git a/tests/AgentCore.Application.Tests/Runtime/Compaction/SummaryRowProviderNoticeTests.cs b/tests/AgentCore.Application.Tests/Runtime/Compaction/SummaryRowProviderNoticeTests.cs index 566c87e2..bcf3afd8 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Compaction/SummaryRowProviderNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Compaction/SummaryRowProviderNoticeTests.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Transcript; @@ -7,12 +6,13 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; namespace AgentCore.Application.Tests.Runtime.Compaction { /// - /// D2: a start notice goes out only when the summariser model is actually called, and an end + /// A start notice goes out only when the summariser model is actually called, and an end /// notice always follows it with the outcome the pass had. Every fact here comes from a real /// streaming turn, so the notice count is read off the wire the caller would see, not off an /// internal hook. diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs index 8b54325b..011794a1 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs @@ -3,8 +3,9 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Transcript; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; @@ -12,11 +13,12 @@ using AgentCore.TestSupport; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { /// - /// The busy mark a turn puts on its conversation in the store (owner ruling for soak F1, after LangGraph's thread + /// The busy mark a turn puts on its conversation in the store (modelled on LangGraph's thread /// busy status and its enqueue strategy): a turn of another session waits for it, then reads the /// conversation afresh. /// @@ -99,12 +101,39 @@ public async Task ACallerThatLeavesWhileTheTurnWaits_DropsTheTurn_WithAWarningAn // Assert _ = Assert.IsAssignableFrom(dropped); Assert.Empty(model.Requests); - AuditEvent refused = Assert.Single(sink.EventsOf(ConversationId), item => item.Kind == AuditEventKind.TurnRefused); + AuditEvent refused = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnRefused); Assert.Equal(("gone", null), (refused.Payload[AuditPayloadKeys.RefusedReason], refused.TurnIndex)); CapturedLine line = Assert.Single(logs.Of(40)); Assert.Equal((LogLevel.Warning, "gone"), (line.Level, line.Field("Reason"))); } + // A refusal before the store opened starts the conversation; the turn that later opens it starts nothing more. + [Fact(Timeout = 30_000)] + public async Task ATurnAfterARefusedOneStartsNoSecondConversation() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + InMemoryConversationStore store = new(time); + RequestRecordingChatClient model = new("hello"); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(model, store, time, sink, logger: null).Create(ConversationId); + _ = await store.TryMarkBusyAsync(ConversationId, "other-host", TimeSpan.FromHours(1), Ct); + using CancellationTokenSource caller = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task dropped = session.RunTurnAsync("hi", caller.Token); + _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.FirstPoll, 1), dropped); + await caller.CancelAsync(); + _ = await Record.ExceptionAsync(() => dropped); + await store.ClearBusyAsync(ConversationId, "other-host", Ct); + + // Act + _ = await session.RunTurnAsync("hi again", Ct); + + // Assert + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnRefused, AuditEventKind.TurnCompleted], + (await session.RowsAsync(sink)).Select(row => row.Kind)); + } + private static ConversationSessionFactory Build( RequestRecordingChatClient model, IConversationStore store, TimeProvider time, InMemoryAuditSink sink, ILogger? logger) { @@ -118,7 +147,7 @@ private static ConversationSessionFactory Build( extractor: null, timeProvider: time, logger: logger, - observers: ConversationObservers.Standard(sink, logger)); + hooks: BuiltInHooks.Create(sink, logger)); } /// Reports the first time a turn found the conversation marked by another session. diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs deleted file mode 100644 index 48efbd02..00000000 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Xunit; - -namespace AgentCore.Application.Tests.Runtime -{ - /// - /// What one conversation event kind is to the audit vocabulary, and what it is called in a log line. - /// - public sealed class ConversationEventKindsTests - { - /// Every kind the chain stores, beside the row it writes and the token it hashes. - public static TheoryData StoredKinds => - new() - { - { ConversationEventKind.ConversationStarted, AuditEventKind.ConversationStarted, "conversation.started" }, - { ConversationEventKind.PromptFlagged, AuditEventKind.PromptFlagged, "prompt.flagged" }, - { ConversationEventKind.ToolFailed, AuditEventKind.ToolFailed, "tool.failed" }, - { ConversationEventKind.TurnCompleted, AuditEventKind.TurnCompleted, "turn.completed" }, - { ConversationEventKind.ReplyInterrupted, AuditEventKind.ReplyInterrupted, "reply.interrupted" }, - { ConversationEventKind.ConversationEnded, AuditEventKind.ConversationEnded, "conversation.ended" }, - { ConversationEventKind.TurnSuperseded, AuditEventKind.TurnSuperseded, "turn.superseded" }, - { ConversationEventKind.TurnRefused, AuditEventKind.TurnRefused, "turn.refused" }, - }; - - /// Every diagnostic kind, beside the name a log line gives it. - public static TheoryData DiagnosticKinds => - new() - { - { ConversationEventKind.ModerationUnavailable, "moderation.unavailable" }, - { ConversationEventKind.ModerationClean, "moderation.clean" }, - { ConversationEventKind.EmptyReply, "reply.empty" }, - { ConversationEventKind.ExtractionFailed, "extraction.failed" }, - { ConversationEventKind.TranscriptWriteFailed, "transcript.write.failed" }, - { ConversationEventKind.StateRestorePartial, "state.restore.partial" }, - { ConversationEventKind.TranscriptResyncFailed, "transcript.resync.failed" }, - { ConversationEventKind.RunFaulted, "run.faulted" }, - }; - - [Theory] - [MemberData(nameof(StoredKinds))] - public void AStoredKind_NamesItsRowAndItsToken(ConversationEventKind kind, AuditEventKind expected, string token) - { - Assert.True(ConversationEventKinds.TryGetAuditKind(kind, out AuditEventKind mapped)); - Assert.Equal(expected, mapped); - - // The token is the chain's, and it is a permanent promise. Nothing here invents one. - Assert.Equal(token, ConversationEventKinds.ToToken(kind)); - Assert.Equal(AuditEventKinds.ToToken(expected), ConversationEventKinds.ToToken(kind)); - } - - [Theory] - [MemberData(nameof(DiagnosticKinds))] - public void ADiagnosticKind_MapsToNoRowAndIsStillNamed(ConversationEventKind kind, string token) - { - Assert.False(ConversationEventKinds.TryGetAuditKind(kind, out _)); - - // It reaches no chain, so AuditEventKinds knows no token for it. A report still has to name it. - Assert.Equal(token, ConversationEventKinds.ToToken(kind)); - } - - [Fact] - public void EveryKind_IsEitherStoredOrDiagnostic_AndEveryOneIsCovered() - { - int stored = Enum.GetValues() - .Count(kind => ConversationEventKinds.TryGetAuditKind(kind, out _)); - - Assert.Equal(16, Enum.GetValues().Length); - Assert.Equal(8, stored); - } - - [Fact] - public void AValueOutsideTheClosedSet_IsNamedRatherThanThrownOver() - { - // A kind the enum does not hold must not cost a report the fault it is carrying. - const ConversationEventKind Unknown = (ConversationEventKind)999; - - Assert.False(ConversationEventKinds.TryGetAuditKind(Unknown, out _)); - Assert.Equal("999", ConversationEventKinds.ToToken(Unknown)); - } - } -} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationObserverDispatcherTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationObserverDispatcherTests.cs deleted file mode 100644 index 6bebcfdf..00000000 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationObserverDispatcherTests.cs +++ /dev/null @@ -1,373 +0,0 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Application.Tests.Diagnostics; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace AgentCore.Application.Tests.Runtime -{ - /// - /// The one seam between the turn loop and everything that watches it. - /// - /// - /// Section 7 measures a durable insert at 13 ms p50 against 91 nanoseconds to enqueue, so the rule - /// these tests fix is that no observer ever sits on the turn: a fast one costs the caller nothing but - /// the conversation itself, a slow one is watched off-turn, and one that throws is reported and forgotten. - /// The fourth rule is the one the old fire-and-forget code did not have. The chain of D23 is a record - /// of a conversation, so the facts of one conversation must reach an observer in the order the conversation produced them, - /// however long any one of them takes. - /// - public sealed class ConversationObserverDispatcherTests - { - private const string ConversationId = "conversation-1"; - - /// The id Log.AuditAppendFailed carries. It is unchanged by the hook. - private const int AuditAppendFailedEventId = 5; - - [Fact] - public void AnObserverThatAnswersAtOnce_HasTheEventBeforeDispatchReturns() - { - RecordingObserver observer = new(); - ConversationObserverDispatcher dispatcher = new([observer]); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationStarted)); - - // No await anywhere: a synchronous observer runs on the caller's thread, which is what makes - // the fast path cost the turn the enqueue and nothing else. - Assert.Equal([ConversationEventKind.ConversationStarted], observer.Seen); - } - - [Fact] - public void ManyEventsThroughTheFastPath_ArriveInOrder() - { - RecordingObserver observer = new(); - ConversationObserverDispatcher dispatcher = new([observer]); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationStarted)); - dispatcher.Dispatch(Event(ConversationEventKind.TurnCompleted)); - dispatcher.Dispatch(Event(ConversationEventKind.ConversationEnded)); - - Assert.Equal( - [ConversationEventKind.ConversationStarted, ConversationEventKind.TurnCompleted, ConversationEventKind.ConversationEnded], - observer.Seen); - } - - [Fact] - public void EveryObserver_ReadsTheSameEvent_InTheOrderTheyWereGiven() - { - RecordingObserver first = new(); - RecordingObserver second = new(); - List order = []; - first.OnEvent = _ => order.Add("first"); - second.OnEvent = _ => order.Add("second"); - ConversationObserverDispatcher dispatcher = new([first, second]); - - dispatcher.Dispatch(Event(ConversationEventKind.ToolFailed)); - - Assert.Equal([ConversationEventKind.ToolFailed], first.Seen); - Assert.Equal([ConversationEventKind.ToolFailed], second.Seen); - Assert.Equal(["first", "second"], order); - } - - [Fact] - public void NoObserversAtAll_IsANoOp() - { - ConversationObserverDispatcher dispatcher = new([]); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationStarted)); - - Assert.Equal(0, dispatcher.Count); - } - - [Fact] - public async Task AnObserverThatBlocks_DoesNotBlockTheCaller() - { - GatedObserver observer = new(); - ConversationObserverDispatcher dispatcher = new([observer]); - - // The observer is inside OnConversationEventAsync and will stay there until the test releases it. - // Dispatch still returns, which is the whole contract of the port. - dispatcher.Dispatch(Event(ConversationEventKind.TurnCompleted)); - - await observer.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); - Assert.Empty(observer.Seen); - - observer.Release(); - await WaitFor(() => observer.Seen.Count == 1); - } - - [Fact] - public async Task TwoSlowEvents_ReachTheObserverInTheOrderTheConversationRaisedThem() - { - // The old code was `_ = ObserveAppendAsync(...)`, so the second event overtook the first - // whenever the first was slower. The second gate is released FIRST here, which is exactly - // the race that used to shuffle the chain. - GatedObserver observer = new(); - ConversationObserverDispatcher dispatcher = new([observer]); - - dispatcher.Dispatch(Event(ConversationEventKind.TurnCompleted)); - await observer.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationEnded)); - - // The second event is queued behind the first, so the observer has not even been asked about - // it yet. There is nothing to release. - Assert.Equal(1, observer.Entries); - - observer.Release(); - await WaitFor(() => observer.Seen.Count == 2); - - Assert.Equal([ConversationEventKind.TurnCompleted, ConversationEventKind.ConversationEnded], observer.Seen); - } - - [Fact] - public async Task ASlowObserver_DoesNotDelayALaterEventForAFastOne() - { - // Order is a guarantee per observer, and only per observer. A single shared tail would put - // the counters and the log lines of every later fact behind the sink still writing an earlier - // row, which is the cost section 8.6 and section 8.7 are not allowed to pay. - GatedObserver slow = new(); - RecordingObserver fast = new(); - ConversationObserverDispatcher dispatcher = new([slow, fast]); - - dispatcher.Dispatch(Event(ConversationEventKind.TurnCompleted)); - await slow.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); - - // The slow observer is inside OnConversationEventAsync for the first event and stays there. - dispatcher.Dispatch(Event(ConversationEventKind.ConversationEnded)); - - // No await anywhere between the dispatch and this assertion: the second fact reached the fast - // observer on the caller's thread while the first one was still open at the slow one. - Assert.Equal([ConversationEventKind.TurnCompleted, ConversationEventKind.ConversationEnded], fast.Seen); - - // And the slow observer was never asked about the second fact, because its own first one is - // still open. Its order is kept while it costs nobody else anything. - Assert.Equal(1, slow.Entries); - - slow.Release(); - await WaitFor(() => slow.Seen.Count == 2); - - Assert.Equal([ConversationEventKind.TurnCompleted, ConversationEventKind.ConversationEnded], slow.Seen); - } - - [Fact] - public async Task AQueuedEvent_StillArrivesWhenTheEventBeforeItFailed() - { - GatedObserver observer = new() { ThrowsOn = ConversationEventKind.TurnCompleted }; - ConversationObserverDispatcher dispatcher = new([observer]); - - dispatcher.Dispatch(Event(ConversationEventKind.TurnCompleted)); - await observer.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationEnded)); - observer.Release(); - - await WaitFor(() => observer.Seen.Count == 1); - Assert.Equal([ConversationEventKind.ConversationEnded], observer.Seen); - } - - [Fact] - public void AnObserverThatThrowsAtOnce_NeitherPropagatesNorCostsTheOthersTheEvent() - { - RecordingLogger logger = new(); - ThrowingObserver throwing = new(); - RecordingObserver recording = new(); - ConversationObserverDispatcher dispatcher = new([throwing, recording], logger); - - // Audit is a record of the conversation and never a part of it, so nothing here reaches the turn. - dispatcher.Dispatch(Event(ConversationEventKind.ConversationStarted)); - - Assert.Equal([ConversationEventKind.ConversationStarted], recording.Seen); - - LogLine line = Assert.Single(logger.Of(AuditAppendFailedEventId)); - Assert.Equal(LogLevel.Error, line.Level); - Assert.Contains("conversation.started", line.Message, StringComparison.Ordinal); - Assert.Contains(ConversationId, line.Message, StringComparison.Ordinal); - _ = Assert.IsType(line.Exception); - } - - [Fact] - public async Task AnObserverThatThrowsLongAfterTheTurn_IsReportedAndNothingElseHappens() - { - RecordingLogger logger = new(); - GatedObserver observer = new() { ThrowsOn = ConversationEventKind.ConversationEnded }; - ConversationObserverDispatcher dispatcher = new([observer], logger); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationEnded)); - await observer.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); - - observer.Release(); - await WaitFor(() => logger.Of(AuditAppendFailedEventId).Count == 1); - - LogLine line = Assert.Single(logger.Of(AuditAppendFailedEventId)); - Assert.Contains("conversation.ended", line.Message, StringComparison.Ordinal); - _ = Assert.IsType(line.Exception); - } - - [Fact] - public void ADiagnosticKind_IsReportedUnderItsOwnToken() - { - RecordingLogger logger = new(); - ConversationObserverDispatcher dispatcher = new([new ThrowingObserver()], logger); - - // The four diagnostic kinds reach no chain, so AuditEventKinds knows no token for them. The - // report still has to name what failed. - dispatcher.Dispatch(Event(ConversationEventKind.ExtractionFailed)); - - LogLine line = Assert.Single(logger.Of(AuditAppendFailedEventId)); - Assert.Contains("extraction.failed", line.Message, StringComparison.Ordinal); - } - - [Fact] - public void AFailingObserver_DoesNotStopTheNextEvent() - { - ThrowingObserver throwing = new(); - RecordingObserver recording = new(); - ConversationObserverDispatcher dispatcher = new([throwing, recording]); - - dispatcher.Dispatch(Event(ConversationEventKind.ConversationStarted)); - dispatcher.Dispatch(Event(ConversationEventKind.ConversationEnded)); - - Assert.Equal([ConversationEventKind.ConversationStarted, ConversationEventKind.ConversationEnded], recording.Seen); - } - - [Fact] - public void NoEvent_IsRefused() - { - _ = Assert.Throws( - () => new ConversationObserverDispatcher([new RecordingObserver()]).Dispatch(null!)); - } - - private static ConversationEvent Event(ConversationEventKind kind) - { - return new() - { - ConversationId = ConversationId, - Kind = kind, - OccurredAt = DateTimeOffset.UnixEpoch, - }; - } - - /// Waits for something an observer does off-turn, and fails rather than hanging. - private static async Task WaitFor(Func condition) - { - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - while (!condition()) - { - await Task.Delay(5, deadline.Token); - } - } - - /// - /// An observer that answers at once and remembers what it read. - /// - private sealed class RecordingObserver : IConversationObserver - { - private readonly Lock _gate = new(); - private readonly List _seen = []; - - /// Gets what the observer read, oldest first. - public IReadOnlyList Seen - { - get - { - lock (_gate) - { - return [.. _seen]; - } - } - } - - /// Runs beside the recording, so a test can watch the order across observers. - public Action? OnEvent { get; set; } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - Assert.False(cancellationToken.CanBeCanceled, "The dispatcher passes CancellationToken.None."); - - lock (_gate) - { - _seen.Add(conversationEvent.Kind); - } - - OnEvent?.Invoke(conversationEvent); - return ValueTask.CompletedTask; - } - } - - /// - /// An observer that holds every event open until a test releases it. - /// - /// - /// It records AFTER the gate, so answers what the observer finished and - /// answers what it was asked about. The difference is what an ordering test - /// reads. - /// - private sealed class GatedObserver : IConversationObserver - { - private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly Lock _lock = new(); - private readonly List _seen = []; - private int _entries; - - /// Gets the events the observer accepted, oldest first. - public IReadOnlyList Seen - { - get - { - lock (_lock) - { - return [.. _seen]; - } - } - } - - /// Gets the number of times the dispatcher has called the observer. - public int Entries => Volatile.Read(ref _entries); - - /// Set when the dispatcher first calls the observer. - public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// The one kind the observer refuses, or when it refuses none. - public ConversationEventKind? ThrowsOn { get; init; } - - /// Lets every open conversation complete. - public void Release() - { - _ = _gate.TrySetResult(); - } - - public async ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - _ = Interlocked.Increment(ref _entries); - _ = Entered.TrySetResult(); - - await _gate.Task.ConfigureAwait(false); - - if (ThrowsOn == conversationEvent.Kind) - { - throw new InvalidOperationException(ThrowingObserver.Message); - } - - lock (_lock) - { - _seen.Add(conversationEvent.Kind); - } - } - } - - /// - /// An observer that refuses every event before it has awaited anything. - /// - private sealed class ThrowingObserver : IConversationObserver - { - /// The message every refusal carries. - public const string Message = "the observer is broken."; - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - throw new InvalidOperationException(Message); - } - } - } -} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationObserversTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationObserversTests.cs deleted file mode 100644 index 17bef96d..00000000 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationObserversTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using AgentCore.Application.Audit; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Domain.Audit; -using Xunit; - -namespace AgentCore.Application.Tests.Runtime -{ - /// - /// assembles the readings of one conversation, in the order the cost - /// story of section 7 asks for. - /// - /// - /// The order is the contract: the counters of section 8.6 and the rows of section 8.7 are taken - /// above the enqueue, and code this library did not write goes after all three. The list is what - /// is handed, so this is where the rule is tested rather than - /// inside the factory that no longer knows it. - /// - public sealed class ConversationObserversTests - { - /// A host reading that records nothing and only has to be found in the list. - private sealed class HostObserver : IConversationObserver - { - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - return ValueTask.CompletedTask; - } - } - - /// A sink that accepts everything, so that the audit reading has something to write to. - private sealed class AcceptingSink : IAuditSinkPort - { - public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken) - { - return ValueTask.CompletedTask; - } - } - - [Fact] - public void EveryLibraryReadingIsTaken() - { - // The audit reading is no longer conditional on a host having bound something. The - // composition root resolves providers.audit for every host and falls back to the in-process - // memory kind, so all three readings of a conversation are always built and the chain of D23 is - // written whatever the document names. - IReadOnlyList observers = ConversationObservers.Standard(new AcceptingSink(), logger: null); - - Assert.Equal(3, observers.Count); - _ = Assert.IsType(observers[2]); - } - - [Fact] - public void NoSinkIsNotAShapeTheListCanHave() - { - // There is no "bound nothing" list to build, so asking for one is a caller's mistake rather - // than a two-observer reading of the conversation. - _ = Assert.Throws( - "auditSink", - () => ConversationObservers.Standard(auditSink: null!, logger: null)); - } - - [Fact] - public void BoundSinkPutsTheAuditReadingLast() - { - IReadOnlyList observers = ConversationObservers.Standard(new AcceptingSink(), logger: null); - - Assert.Collection( - observers, - observer => Assert.IsType(observer), - observer => Assert.IsType(observer), - observer => Assert.IsType(observer)); - } - - [Fact] - public void HostReadingsComeAfterEveryLibraryReading() - { - HostObserver host = new(); - - IReadOnlyList observers = ConversationObservers.Standard(new AcceptingSink(), logger: null, [host]); - - Assert.Collection( - observers, - observer => Assert.IsType(observer), - observer => Assert.IsType(observer), - observer => Assert.IsType(observer), - observer => Assert.Same(host, observer)); - } - } -} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionApprovalTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionApprovalTests.cs index cc888dfa..e8c80d3b 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionApprovalTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionApprovalTests.cs @@ -1,12 +1,12 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { @@ -14,9 +14,6 @@ namespace AgentCore.Application.Tests.Runtime /// The approval round trip through the turn loop: a gated tool suspends the turn with a request /// instead of failing it, and the caller's answer on the same conversation runs the tool. /// - /// - /// Every test here runs offline. There is no network conversation and no API key anywhere in this file. - /// public sealed class ConversationSessionApprovalTests { private const string GatedYaml = @@ -140,7 +137,7 @@ public async Task AGatedToolCall_KeepsTheQueueInTheSnapshot() _ = await session.RunTurnAsync("send it", token); - Assert.True(session.Snapshot().Providers.ContainsKey("_pendingApprovalRequests")); + Assert.True(session.States.Snapshot().Providers.ContainsKey("_pendingApprovalRequests")); } private static ConversationSession Session(string yaml, Action onSend, CancellationToken token) diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs index 316924c8..99afc5a4 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs @@ -1,13 +1,14 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime { - /// Item 6a: barge-in. The record holds what the caller heard. + /// Barge-in. The record holds what the caller heard. public sealed class ConversationSessionBargeInTests { [Fact] @@ -28,7 +29,7 @@ public async Task AnInterruption_RecordsTheHeardTextAndTheDurationTheRelayReport Assert.True(await updates.MoveNextAsync()); Assert.Equal("I can help with that,", updates.Current.Text); - // Section 7.1 reports both values on the interrupt frame, at 1 ms. Nothing estimates either. + // Both values are reported on the interrupt frame, at 1 ms. Nothing estimates either. Assert.True(session.Cut(0, new TurnCut("I can help", TimeSpan.FromMilliseconds(740)))); Assert.False(await updates.MoveNextAsync()); @@ -76,8 +77,8 @@ public async Task AnInterruption_RunsEveryWriterAndLeavesTheSessionReady() Assert.Null(next.Cut); } - // Probe W04 (design section 3, row "Cut before output"): a cut of a buffered turn that names nothing shown - // keeps its user message only, and the turn before it stays as it was. + // A cut of a buffered turn that names nothing shown keeps its user message only, + // and the turn before it stays as it was. [Fact] public async Task AnInterruptionDuringABufferedTurn_CutsThatTurnWithNothingShown_AndLeavesThePreviousTurn() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs index 196f0f26..104d58dd 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs @@ -1,13 +1,15 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; namespace AgentCore.Application.Tests.Runtime { /// - /// on a turn that already committed (design section 3, row "sealed"). + /// on a turn that already committed. /// public sealed class ConversationSessionCommittedCutTests { @@ -15,7 +17,7 @@ public sealed class ConversationSessionCommittedCutTests private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Probe P1b (REPORT.md, F2): a cut that shows nothing of a committed reply leaves it carrying nothing, so + // A cut that shows nothing of a committed reply leaves it carrying nothing, so // the row goes, as a running turn's cut drops it; LiveKit adds a message only if forwarded_text // (agent_activity.py:3335). The next model is never read an assistant message with no content. [Fact] @@ -40,7 +42,7 @@ public async Task EmptyCutOfACommittedReply_RemovesTheReplyRow_AndTheNextModelRe Assert.All(sent, message => Assert.NotEmpty(message.Contents)); } - // Probe P7d, owner ruling 2026-09-24: once turn 1 has started, a cut naming turn 0 is stale. It reports + // Once turn 1 has started, a cut naming turn 0 is stale. It reports // false and turn 0's reply stays as it was saved, while turn 1 still runs and commits. [Fact] public async Task CutOfThePreviousTurn_WhileTheNextTurnRuns_IsRefusedAndChangesNothing() diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs index a4609dd2..7a76d5ea 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs @@ -3,10 +3,12 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { @@ -28,12 +30,13 @@ internal static ConversationSession Create( document, new AgentCompilationContext(new FakeChatClientFactory(reply)) { ConversationStore = store })["main"]; + InMemoryAuditSink rows = sink ?? new InMemoryAuditSink(); ConversationSessionFactory factory = new( compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null, timeProvider: time, - observers: ConversationObservers.Standard(sink ?? new InMemoryAuditSink(), logger: null)); + hooks: BuiltInHooks.Create(rows)); return factory.Create(); } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs index 1e2133ad..7cc38ba5 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs @@ -1,25 +1,27 @@ using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; namespace AgentCore.Application.Tests.Runtime { /// - /// and the Cut rule over a whole conversation - /// (docs/handoff/2026-09-22-maf-native-engine-design.md, section 3). + /// and the Cut rule over a whole conversation. /// public sealed class ConversationSessionCutTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Inverse of probe P14a (row "Host cancel / text Stop", W05): a host cancel mid-reply keeps the user + // A host cancel mid-reply keeps the user // message and everything yielded, and the host still gets its cancellation. [Fact] public async Task HostCancelMidReply_KeepsTheUserMessageAndTheYieldedText() @@ -45,7 +47,7 @@ public async Task HostCancelMidReply_KeepsTheUserMessageAndTheYieldedText() Assert.Equal("Hel", session.LastTurn.ReplyText); } - // Probe W06 through the conversation: a host that stops reading still gets the turn committed, and the + // A host that stops reading still gets the turn committed, and the // conversation takes the next turn. [Fact] public async Task HostAbandonsTheStream_KeepsTheUserAndTheYieldedText_AndTheNextTurnRuns() @@ -71,7 +73,7 @@ public async Task HostAbandonsTheStream_KeepsTheUserAndTheYieldedText_AndTheNext Assert.Equal(2, store.Appends); } - // Inverse of probe P14b (W04, G1): an interrupt before the running turn yields anything keeps that turn's user + // An interrupt before the running turn yields anything keeps that turn's user // message only, and the turn before it is untouched. [Fact] public async Task CutBeforeAnyContent_KeepsTheUserMessageOnly_AndLeavesThePreviousTurn() @@ -105,7 +107,7 @@ public async Task CutBeforeAnyContent_KeepsTheUserMessageOnly_AndLeavesThePrevio Assert.Equal(string.Empty, session.LastTurn.ReplyText); } - // Design section 2, seams: StartTurnAsync hands back the turn's index before the reply is read, and the + // StartTurnAsync hands back the turn's index before the reply is read, and the // reply is the turn's streamed updates. [Fact] public async Task StartTurn_NamesTheTurnBeforeItsReplyIsRead() @@ -119,7 +121,7 @@ public async Task StartTurn_NamesTheTurnBeforeItsReplyIsRead() List texts = []; await foreach (ChatResponseUpdate update in run.Updates) { - // The trailing update is the turn's committed ids (design section 6, step E5), not text. + // The trailing update is the turn's committed ids, not text. if (update.Contents.OfType().Any()) { continue; @@ -134,7 +136,7 @@ public async Task StartTurn_NamesTheTurnBeforeItsReplyIsRead() Assert.Equal(1, session.LastTurn!.TurnIndex); } - // Probe W04 (G1) on the voice seam: a cut that reaches a started turn before its reply is read keeps the + // On the voice seam: a cut that reaches a started turn before its reply is read keeps the // user message only, and the turn before it is untouched. [Fact] public async Task CutBeforeTheReplyIsRead_KeepsTheUserMessageOnly_AndLeavesThePreviousTurn() @@ -161,7 +163,7 @@ public async Task CutBeforeTheReplyIsRead_KeepsTheUserMessageOnly_AndLeavesThePr Assert.Equal(string.Empty, session.LastTurn.ReplyText); } - // Section 3, row "sealed": a cut of a committed turn rewrites its reply, never appends, and raises + // A cut of a committed turn rewrites its reply, never appends, and raises // reply.interrupted amending turn.completed, with no duration when nothing played. [Fact] public async Task CutOfACommittedTurn_RewritesTheReply_AndAmendsTurnCompletedWithoutADuration() @@ -179,7 +181,7 @@ public async Task CutOfACommittedTurn_RewritesTheReply_AndAmendsTurnCompletedWit Assert.Equal(["hi", "Hello"], Texts(session.Transcript)); Assert.Equal(1, store.Appends); _ = Assert.Single(store.Rewrites); - IReadOnlyList events = sink.EventsOf(session.ConversationId); + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); AuditEvent amendment = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal(completed.EventId, amendment.AmendsEventId); @@ -187,7 +189,7 @@ public async Task CutOfACommittedTurn_RewritesTheReply_AndAmendsTurnCompletedWit Assert.False(amendment.Payload.ContainsKey(AuditPayloadKeys.DurationUntilInterruptMs)); } - // Section 3, row "unknown or older": false, and nothing changes. + // An unknown or older turn: false, and nothing changes. [Fact] public async Task CutOfAnUnknownOrOlderTurn_ReportsFalseAndChangesNothing() { @@ -208,7 +210,7 @@ public async Task CutOfAnUnknownOrOlderTurn_ReportsFalseAndChangesNothing() Assert.Equal(["q1", "a1", "q2", "a2"], Texts(session.Transcript)); } - // Probe W10: a cut racing the turn's completion, 300 runs. Always one append and at most one rewrite, the + // A cut racing the turn's completion, 300 runs. Always one append and at most one rewrite, the // final text is the shown text, and both branches (cut while running, rewrite after the seal) are hit. [Fact] public async Task CutRacesCompletion_AlwaysOneAppend_AtMostOneRewrite_FinalTextIsShown_BothBranchesHit() @@ -264,7 +266,7 @@ public async Task CutRacesCompletion_AlwaysOneAppend_AtMostOneRewrite_FinalTextI Assert.True(amended > 0, $"amended={amended}"); } - // Owner ruling 2026-09-23: disposing a run nobody read frees the conversation, and the turn commits nothing. + // Disposing a run nobody read frees the conversation, and the turn commits nothing. [Fact] public async Task AnUnreadRunDisposed_FreesTheConversationForTheNextTurn() { @@ -283,7 +285,7 @@ public async Task AnUnreadRunDisposed_FreesTheConversationForTheNextTurn() Assert.Equal(["one", "first reply", "three", "second reply"], Texts(session.Transcript)); } - // Owner ruling 2026-09-23: a half-read run disposed frees the conversation, and disposing it twice is safe. + // A half-read run disposed frees the conversation, and disposing it twice is safe. [Fact] public async Task AHalfReadRunDisposedTwice_FreesTheConversationForTheNextTurn() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs index 4e73fe37..6392aeea 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs @@ -1,24 +1,27 @@ using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; namespace AgentCore.Application.Tests.Runtime { /// /// at the edges of a turn's window: after its commit, after its run was - /// abandoned, and after the conversation ended (docs/handoff/2026-09-22-maf-native-engine-design.md, section 3). + /// abandoned, and after the conversation ended. /// public sealed class ConversationSessionCutWindowTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - // TurnCutSlot remarks: a cut after the commit belongs to the committed turn. It lands while the reader still + // A cut after the commit belongs to the committed turn. It lands while the reader still // holds the turn's last update, so the run has not ended, and it rewrites the reply the commit wrote. [Fact] public async Task ACutAfterTheCommit_WhileTheRunIsStillOpen_RewritesTheCommittedReply() @@ -44,10 +47,10 @@ public async Task ACutAfterTheCommit_WhileTheRunIsStillOpen_RewritesTheCommitted Assert.Equal(["hi", "Hello"], Texts(session.Transcript)); Assert.Equal(1, store.Appends); _ = Assert.Single(store.Rewrites); - _ = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + _ = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); } - // Owner ruling 2026-09-23: a run disposed unread commits nothing, so a cut that names it has no turn to reach. + // A run disposed unread commits nothing, so a cut that names it has no turn to reach. [Fact] public async Task ACutOfARunDisposedUnread_ReportsFalse_AndChangesNothing() { @@ -83,8 +86,8 @@ public async Task ACutAfterTheConversationEnded_ReportsFalse_AndChangesNothing() // Assert Assert.False(recorded); Assert.Empty(store.Rewrites); - Assert.Equal(AuditEventKind.ConversationEnded, sink.EventsOf(session.ConversationId)[^1].Kind); - Assert.DoesNotContain(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + Assert.Equal(AuditEventKind.ConversationEnded, (await session.RowsAsync(sink))[^1].Kind); + Assert.DoesNotContain(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); } // IConversationPort.Cut: a negative played duration is refused. diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs new file mode 100644 index 00000000..e2970bdf --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs @@ -0,0 +1,124 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Runtime +{ + // An edit that withdraws the turn that asked takes its open approval requests with it: nothing answers them. + public sealed class ConversationSessionEditApprovalTests + { + private const string TwoToolsYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: send_email, kind: builtin, uses: test.send_email, description: "Send an email." } + - { id: send_sms, kind: builtin, uses: test.send_sms, description: "Send a text message." } + agents: + items: + - id: only + instructions: "send both" + tools: [ send_email, send_sms ] + entries: + main: + agent: only + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AnEditOfATurnWhoseRequestsAreOpenLeavesNoAnswerWithoutItsRequest(bool underAnAutoRule) + { + string yaml = underAnAutoRule + ? TwoToolsYaml.Replace("tools: [ send_email, send_sms ]", "tools: [ send_email, send_sms ]\n approval: { auto: [ nothing_matches ] }", StringComparison.Ordinal) + : TwoToolsYaml; + int ran = 0; + InMemoryConversationStore store = new(); + RecordingHook hook = new(); + ConversationSession session = HookSessions.Create( + yaml, + new TwoGatedCallsChatClient(), + [hook], + store: store, + tools: declared => declared.Uses is "test.send_email" or "test.send_sms" + ? new ApprovalRequiredAIFunction(AIFunctionFactory.Create((string to) => { ran++; return "sent"; }, declared.Id, declared.Id)) + : null); + + TurnResult asked = await session.RunTurnAtOriginAsync("send both", new ConversationTurnOrigin("d1", null) { NamesParent = true }, Ct); + Assert.NotEmpty(asked.Approvals); + + TurnResult edited = await session.RunTurnAtOriginAsync("send nothing", new ConversationTurnOrigin("d2", null) { NamesParent = true }, Ct); + Assert.Null(edited.Failure); + await session.FlushTranscriptAsync(); + + HashSet requested = [.. session.Transcript.SelectMany(m => m.Contents).OfType().Select(r => r.RequestId)]; + List orphanAnswers = [.. session.Transcript.SelectMany(m => m.Contents).OfType().Where(a => !requested.Contains(a.RequestId)).Select(a => a.RequestId)]; + + TurnResult next = await session.RunTurnAsync("thanks", Ct); + await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); + await session.DisposeAsync(); + ConversationSession reloaded = HookSessions.Create(yaml, new TwoGatedCallsChatClient(), store: store, conversationId: session.ConversationId, + tools: declared => declared.Uses is "test.send_email" or "test.send_sms" + ? new ApprovalRequiredAIFunction(AIFunctionFactory.Create((string to) => { ran++; return "sent"; }, declared.Id, declared.Id)) + : null); + TurnResult afterReload = await reloaded.RunTurnAsync("still there?", Ct); + Assert.Equal((0, (string?)null, (string?)null, 0), (orphanAnswers.Count, next.Failure, afterReload.Failure, ran)); + Assert.DoesNotContain(hook.Of(), changed => changed.State == ApprovalState.Denied && changed.Scope.TurnIndex == edited.TurnIndex); + } + + // One of two requests was answered and its answer held for the other: the edit drops both, and the held answer, + // so it never answers a later request. The fake model reuses its call ids, as a later round may. + [Fact] + public async Task AnEditDropsTheAnswerHeldForAWithdrawnRequest() + { + int ran = 0; + ConversationSession session = HookSessions.Create( + TwoToolsYaml, + new QuietOnEditChatClient(), + tools: declared => declared.Uses is "test.send_email" or "test.send_sms" + ? new ApprovalRequiredAIFunction(AIFunctionFactory.Create((string to) => { ran++; return "sent"; }, declared.Id, declared.Id)) + : null); + + TurnResult asked = await session.RunTurnAtOriginAsync("send both", new ConversationTurnOrigin("d1", null) { NamesParent = true }, Ct); + _ = await session.RunTurnMessageAsync(session.TryCreateApprovalAnswer(asked.Approvals[0].RequestId, approved: true)!, Ct); + _ = await session.RunTurnAtOriginAsync(QuietOnEditChatClient.Edit, new ConversationTurnOrigin("d2", null) { NamesParent = true }, Ct); + TurnResult again = await session.RunTurnAsync("send both again", Ct); + TurnResult half = await session.RunTurnMessageAsync(session.TryCreateApprovalAnswer(again.Approvals[1].RequestId, approved: true)!, Ct); + + Assert.Equal((0, asked.Approvals[0].RequestId), (ran, Assert.Single(half.Approvals).RequestId)); + } + + /// Calls both tools, except when the newest user words are the edit's, which it answers in words. + private sealed class QuietOnEditChatClient() : DelegatingChatClient(new TwoGatedCallsChatClient()) + { + public const string Edit = "send nothing"; + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List request = [.. messages]; + if (request.LastOrDefault(message => message.Role == ChatRole.User)?.Text == Edit) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "ok"); + yield break; + } + + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(request, options, cancellationToken)) + { + yield return update; + } + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs index 8e3a5c32..f117551b 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs @@ -2,14 +2,18 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; -using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Runtime { @@ -72,35 +76,6 @@ public async Task AnEdit_TakesTheReplacedWordsOutOfWhatTheModelReads() Assert.DoesNotContain("second question", seen); } - [Fact] - public async Task AnEdit_NamesTheTurnsItWithdrew() - { - using ScriptedChatClient reply = new("an answer."); - RecordingObserver observer = new(); - ConversationSession session = CreateSession(OneAgentYaml, reply, observer); - - _ = await session.RunTurnAtOriginAsync( - "q1", - new ConversationTurnOrigin("caller-1", null) { NamesParent = true }, - TestContext.Current.CancellationToken); - string? firstReply = session.LastReplyMessageId; - - _ = await session.RunTurnAsync("q2", TestContext.Current.CancellationToken); - _ = await session.RunTurnAsync("q3", TestContext.Current.CancellationToken); - - _ = await session.RunTurnAtOriginAsync( - "q2, rewritten", - new ConversationTurnOrigin("caller-4", firstReply) { NamesParent = true }, - TestContext.Current.CancellationToken); - - // The rows of turns 1 and 2 are deleted by now, so the trail can only say what it was told. - ConversationEvent superseded = Assert.Single( - observer.Events, raised => raised.Kind == ConversationEventKind.TurnSuperseded); - Assert.Equal("1", superseded.Payload[AuditPayloadKeys.WithdrewFromTurnIndex]); - Assert.Equal("2", superseded.Payload[AuditPayloadKeys.WithdrewThroughTurnIndex]); - Assert.Equal(3, superseded.TurnIndex); - } - [Fact] public async Task AnEditOnATerminalConversation_IsRefusedAndTakesNothing() { @@ -130,8 +105,8 @@ public async Task AnEditOnATerminalConversation_IsRefusedAndTakesNothing() public async Task AnOriginThatNamesNoParent_WithdrawsNothing() { using ScriptedChatClient reply = new("an answer."); - RecordingObserver observer = new(); - ConversationSession session = CreateSession(OneAgentYaml, reply, observer); + RecordingHook hook = new(); + ConversationSession session = CreateSession(OneAgentYaml, reply, hook); _ = await session.RunTurnAsync("q1", TestContext.Current.CancellationToken); @@ -143,7 +118,8 @@ public async Task AnOriginThatNamesNoParent_WithdrawsNothing() TestContext.Current.CancellationToken); Assert.Equal(4, session.Transcript.Count); - Assert.DoesNotContain(observer.Events, raised => raised.Kind == ConversationEventKind.TurnSuperseded); + await session.FlushNoticesAsync(); + Assert.Empty(hook.Of()); } // Clarifications.Withdraw: an edit-and-resend takes back what was last named to the caller, and leaves @@ -173,7 +149,7 @@ public async Task AnEdit_TakesBackWhatWasLastNamedToTheCaller() } private static ConversationSession CreateSession( - string yaml, IChatClient reply, IConversationObserver? observer = null) + string yaml, IChatClient reply, AgentHook? hook = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); FakeChatClientFactory chatClients = new(reply); @@ -189,37 +165,9 @@ private static ConversationSession CreateSession( compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null, - observers: observer is null ? null : [observer]); + hooks: hook is null ? null : [hook]); return factory.Create("conversation-1"); } - - /// Keeps every fact of the conversation, in the order the turn loop raised them. - private sealed class RecordingObserver : IConversationObserver - { - private readonly Lock _gate = new(); - private readonly List _events = []; - - public IReadOnlyList Events - { - get - { - lock (_gate) - { - return [.. _events]; - } - } - } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - lock (_gate) - { - _events.Add(conversationEvent); - } - - return ValueTask.CompletedTask; - } - } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs index 6a4fed12..47b3943c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs @@ -1,13 +1,14 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime { - /// Section 8.7, last row: the run returns quietly with no text. + /// A run that returns quietly with no text. public sealed class ConversationSessionEmptyReplyTests { [Fact] @@ -23,7 +24,7 @@ public async Task AnEmptyReply_SpeaksTheFallbackAndReportsWhyTheTurnFailed() // that is silence, so the turn loop reads the reply rather than trusting the absence of a // failure. Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, turn.Failure); + Assert.Equal(TurnFailureReasons.EmptyReply, turn.Failure); Assert.Null(turn.Cut); // The writers still ran, and the transcript holds what the caller heard. @@ -60,15 +61,15 @@ public async Task Streaming_SpeaksTheFallbackWhenTheRunProducesNoText() updates.Add(update); } - // The host hears the fallback rather than nothing at all: R2 makes a quiet run an ordinary + // The host hears the fallback rather than nothing at all: a quiet run is an ordinary // successful one, and the spoken line leaves the seam the same way any reply does. The - // trailing update is the turn's committed ids (design section 6, step E5), not more text. + // trailing update is the turn's committed ids, not more text. Assert.Equal( [ConversationSession.FallbackReply], updates.Where(update => !update.Contents.OfType().Any()).Select(update => update.Text)); Assert.NotNull(session.LastTurn); Assert.Equal(ConversationSession.FallbackReply, session.LastTurn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, session.LastTurn.Failure); + Assert.Equal(TurnFailureReasons.EmptyReply, session.LastTurn.Failure); Assert.Equal("close", session.Stage); } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryModelFacingTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryModelFacingTests.cs index 438bfc29..f7dfdfb8 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryModelFacingTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryModelFacingTests.cs @@ -1,7 +1,6 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.State; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; @@ -9,6 +8,7 @@ using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs index 5abc8a54..20c355f1 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs @@ -1,9 +1,9 @@ using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Runtime; using AgentCore.Application.State; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime @@ -45,7 +45,7 @@ public async Task TheFactory_GivesEachConversationItsOwnStateAndItsOwnMachine() Assert.Empty(second.Transcript); Assert.NotSame(first.State, second.State); - // Both conversations share the one compiled agent. T44 and rule 16. + // Both conversations share the one compiled agent. Assert.Same(first.Compiled, second.Compiled); } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs index 71455560..554c6ac1 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs @@ -1,16 +1,18 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Domain; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Agents.Graph; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// - /// F2 of the 2026-09-24 round: on a graph row, a caller cancel is a cancel and a model timeout is a fault. + /// On a graph row, a caller cancel is a cancel and a model timeout is a fault. /// MAF's workflow agent throws for neither; it reports a fault as an update and ends its stream quietly on /// a cancel, so has to raise both. /// @@ -50,7 +52,7 @@ public sealed class ConversationSessionGraphCancelTests private static CancellationToken Ct => TestContext.Current.CancellationToken; /// - /// Claim (F2): HttpClient reports a request timeout as even when + /// HttpClient reports a request timeout as even when /// nobody cancelled the turn. That must still reach the turn as a run fault that speaks the fallback, /// not escape as a cancel. /// @@ -61,12 +63,12 @@ public async Task Fault_GraphNodeTimeout_SpeaksTheFallback_InsteadOfEscapingAsAC TurnResult turn = await session.RunTurnAsync("go", Ct); - Assert.StartsWith(ConversationSession.RunFaultReason, turn.Failure, StringComparison.Ordinal); + Assert.StartsWith(TurnFailureReasons.RunFault, turn.Failure, StringComparison.Ordinal); Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, turn.ReplyText); } /// - /// Control for F2: a real caller cancel must still propagate as a cancel, and the stopped turn must not + /// Control: a real caller cancel must still propagate as a cancel, and the stopped turn must not /// keep the fallback line as its reply. A cancel is not a fault, so nothing spoke that line. /// [Theory] @@ -89,10 +91,10 @@ public async Task Fault_ARealCallerCancel_StillPropagatesAsACancel(string yaml) } /// - /// The same claim one layer down, with no turn loop above it. The turn loop's own notice reader also + /// The same behaviour one layer down, with no turn loop above it. The turn loop's own notice reader also /// watches the caller's token and can throw first, which hid this in the session test about two runs in - /// three; here nothing but can turn MAF's quiet end into a cancel. R4-3: - /// the cancel check runs the same way whether the row drains the inner stream or throws at the first + /// three; here nothing but can turn MAF's quiet end into a cancel. + /// The cancel check runs the same way whether the row drains the inner stream or throws at the first /// fault, so this must hold on both. /// [Theory] diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs index 0d6cdad7..410ef714 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; @@ -10,11 +9,15 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Agents.Graph; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// - /// Bug 2 of the 2026-09-24 round: a graph node's fault must reach the turn, on both graph rows. + /// A graph node's fault must reach the turn, on both graph rows. /// AsAIAgent() reports a node fault as an update and never throws, /// so without the turn read an empty reply and lost the tool-versus-run /// distinction carries. @@ -96,7 +99,7 @@ public async Task Fault_ToolFaultInsideAGraphNode_IsStillAToolFault(string yaml) TurnResult turn = await session.RunTurnAsync("go", Ct); - Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); + Assert.StartsWith(TurnFailureReasons.ToolFailure, turn.Failure, StringComparison.Ordinal); // Not just the right bucket: the tool's own fault, not RequireOutputAgent's unrelated // "produced no text" message, which an explicit graph with no GraphFaultAgent throws too @@ -113,7 +116,7 @@ public async Task Fault_ModelDownInsideAGraphNode_IsARunFault(string yaml) TurnResult turn = await session.RunTurnAsync("go", Ct); - Assert.StartsWith(ConversationSession.RunFaultReason, turn.Failure, StringComparison.Ordinal); + Assert.StartsWith(TurnFailureReasons.RunFault, turn.Failure, StringComparison.Ordinal); // Same reason as above: the model's own fault must reach the turn, not RequireOutputAgent's // unrelated "produced no text" message. @@ -121,7 +124,7 @@ public async Task Fault_ModelDownInsideAGraphNode_IsARunFault(string yaml) } /// - /// Claim (R4-3): a concurrent graph whose agents keep no harness state never reuses its workflow + /// A concurrent graph whose agents keep no harness state never reuses its workflow /// session ( is false), so /// must throw at the broken node's first fault instead of draining the still-open slow node. The slow /// node's gate is never opened before the assertion, so a regression that waits for it would time out @@ -154,7 +157,7 @@ public async Task Fault_ConcurrentGraphWithNoReusedSession_SpeaksTheFallbackWith } /// - /// Claim (F3): a graph row that reuses its workflow session must not send a faulted turn's input into + /// A graph row that reuses its workflow session must not send a faulted turn's input into /// the next turn. GraphFaultAgent must let MAF's stream finish (so AddMessages and /// UpdateBookmark run) before it throws. /// @@ -176,7 +179,7 @@ public async Task Fault_ReusedGraphSession_AfterANodeFault_TheNextTurnDoesNotRep } /// - /// Claim (F9): MAF documents as possibly null. Without a + /// MAF documents as possibly null. Without a /// name-the-node fallback, that event's passes through unread and the turn /// sees an empty reply. /// diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs index a9a5ad61..9bf3f4d7 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs @@ -3,16 +3,16 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { /// - /// Bug 1 of the 2026-09-24 round: a graph node's checkpoint must resume under a second compile of the + /// A graph node's checkpoint must resume under a second compile of the /// same document (a restart, or a second host). MAF's InProcessRunner matches a checkpoint by /// executor id, and an agent's executor id folds in /// (AIAgentExtensions.GetDescriptiveId). gives every agent a @@ -85,9 +85,9 @@ public sealed class ConversationSessionGraphIdTests private static CancellationToken Ct => TestContext.Current.CancellationToken; /// - /// Claim: order-lookup and order_lookup are distinct, schema-valid ids. Before the fix, - /// Id = item.Id let MAF's sanitizer (Regex.Replace(Name + "_" + Id, "[^0-9A-Za-z]+", "_")) - /// collapse them to the same executor id, and the compile threw. Both nodes must run and the graph + /// order-lookup and order_lookup are distinct, schema-valid ids. With Id = item.Id, + /// MAF's sanitizer (Regex.Replace(Name + "_" + Id, "[^0-9A-Za-z]+", "_")) would + /// collapse them to the same executor id, and the compile would throw. Both nodes must run and the graph /// must answer with the second node's reply. /// [Theory] diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs index 6165c8bd..53dfb4f6 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs @@ -1,3 +1,5 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Tests.Audit; using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; @@ -5,29 +7,20 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// /// What the caller hears is one reply, on every compiled shape. /// - /// - /// - /// AgentResponse.Text concatenates every message of the response. On a graph row that is - /// every node's reply, so the caller hears the graph deliberating and the audit row records the - /// deliberation as the spoken words. The turn loop takes the last message instead. - /// - /// - /// Every test here runs offline. There is no network conversation and no API key in this file. - /// - /// public sealed class ConversationSessionGraphReplyTests { /// What the first node of the graph says while it works. The caller must not hear it. @@ -84,7 +77,7 @@ public async Task CompleteTurn_GraphRow_SpeaksFinalNodeReplyOnly() Assert.DoesNotContain(Thinking, turn.ReplyText, StringComparison.Ordinal); AuditEvent completed = Assert.Single( - sink.EventsOf("conversation-graph"), + await session.RowsAsync(sink), entry => entry.Kind == AuditEventKind.TurnCompleted); Assert.Equal(AuditHash.OfText(Spoken).Value, completed.Payload[AuditPayloadKeys.ReplyTextSha256]); } @@ -106,7 +99,7 @@ public async Task CompleteTurn_ToolCallingTurn_SpeaksFinalAssistantMessage() Assert.Equal(Spoken, turn.ReplyText); AuditEvent completed = Assert.Single( - sink.EventsOf("conversation-tool"), + await session.RowsAsync(sink), entry => entry.Kind == AuditEventKind.TurnCompleted); Assert.Equal(AuditHash.OfText(Spoken).Value, completed.Payload[AuditPayloadKeys.ReplyTextSha256]); } @@ -126,11 +119,11 @@ public async Task CompleteTurn_GraphRowWhoseFinalNodeGoesQuiet_SpeaksTheFallback // Assert. A quiet answer is silence on a voice conversation, so the turn speaks the fallback. It must // never fall back to the node before it, which is the graph thinking out loud. Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, turn.Failure); + Assert.Equal(TurnFailureReasons.EmptyReply, turn.Failure); Assert.DoesNotContain(Thinking, turn.ReplyText, StringComparison.Ordinal); AuditEvent completed = Assert.Single( - sink.EventsOf("conversation-quiet"), + await session.RowsAsync(sink), entry => entry.Kind == AuditEventKind.TurnCompleted); Assert.Equal( AuditHash.OfText(ConversationSession.FallbackReply).Value, @@ -159,7 +152,7 @@ public async Task Streaming_GraphRowWhoseFinalNodeGoesQuiet_SpeaksTheFallback() Assert.Equal(ConversationSession.FallbackReply, string.Concat(spoken).Trim()); Assert.DoesNotContain(Thinking, string.Concat(spoken), StringComparison.Ordinal); Assert.NotNull(session.LastTurn); - Assert.Equal(ConversationSession.EmptyReplyReason, session.LastTurn.Failure); + Assert.Equal(TurnFailureReasons.EmptyReply, session.LastTurn.Failure); } [Fact] @@ -187,7 +180,7 @@ public async Task Streaming_GraphRow_StreamsEveryFragmentOfTheFinalNodeAndNoneOf [Fact] public async Task Streaming_GraphRow_EndsWithTheIdsOfItsAppend() { - // Arrange. Design section 6, step E5: the committed ids ride the turn's last update, on a row whose + // Arrange. The committed ids ride the turn's last update, on a row whose // output is filtered to its final node as on any other. using ScriptedChatClient researcher = new(Thinking); using ScriptedChatClient responder = new(Spoken); @@ -238,7 +231,7 @@ private static ConversationSessionFactory Build( return new ConversationSessionFactory( compiled, new GuardEvaluator(compiled.Configuration.Guards), - observers: ConversationObservers.Standard(sink, logger: null)); + hooks: BuiltInHooks.Create(sink)); } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs index 9b565163..e25cac9d 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs @@ -2,24 +2,25 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.InterruptionSessions; namespace AgentCore.Application.Tests.Runtime { /// - /// What the transcript and the reply text hold once a barge-in cuts a turn off. Item 6a of section - /// 11: the record holds the text the caller heard, and a side effect that already ran is not dropped. + /// What the transcript and the reply text hold once a barge-in cuts a turn off: the record holds the text the + /// caller heard, and a side effect that already ran is not dropped. /// - /// - /// Every test here runs offline. There is no network conversation and no API key anywhere in this file. - /// public sealed class ConversationSessionInterruptionTests { // What the transcript holds after the caller cuts the reply off. @@ -65,11 +66,11 @@ public async Task AToolCallThatFinishedBeforeTheBargeIn_StaysInTheTranscript() m => m.Contents.OfType().Any()); } - [Fact] - public async Task AParallelToolRound_KeepsTheConversationThatFinishedAndDropsTheOneThatDidNot() + [Fact(Timeout = 30_000)] + public async Task AParallelToolRound_KeepsTheCallThatFinishedAndTheOneStillRunningAtTheCut() { - // A parallel round can pair one call and leave a sibling call, in the same message, mid- - // flight. The rule is per conversation id, so only the unfinished conversation is dropped. + // As in LiveKit, a cut lets the call still in flight finish, and keeps its call and result, so the next + // turn does not run it again. using ParallelToolCallChatClient reply = new(); PartiallyAnsweredToolFactory tools = new(); ConversationSession session = CreateSession(ParallelToolYaml, reply, tools.Create); @@ -93,25 +94,16 @@ public async Task AParallelToolRound_KeepsTheConversationThatFinishedAndDropsThe // The first call already answered by the time the second call is blocked in flight. await tools.SecondConversationStarted.Task.WaitAsync(linked.Token); Assert.True(session.Cut(0, new TurnCut("the first one is", TimeSpan.FromMilliseconds(1820)))); - await pump; - - Assert.DoesNotContain( - session.Transcript, - m => m.Contents.OfType().Any(call => call.CallId == ParallelToolCallChatClient.SecondCallId)); - - // Round one's own message, holding only the finished call, survives either way and is not - // what pins the defect. What pins it is round two's message: a per-message rule drops it - // whole because it also carries the unfinished call, so the finished call only ever shows up - // once (from round one). The per-call-id rule instead trims round two down to the finished - // call and keeps it, so the finished call shows up twice. - List callAMessages = [.. session.Transcript.Where(m => m.Contents.OfType().Any(call => call.CallId == ParallelToolCallChatClient.FirstCallId))]; - Assert.Equal(2, callAMessages.Count); - - ChatMessage trimmedRoundTwo = callAMessages[^1]; - Assert.Equal( - ParallelToolCallChatClient.FirstCallId, - Assert.Single(trimmedRoundTwo.Contents.OfType()).CallId); + tools.ReleaseSecond.SetResult(); + await session.ToolRuns.WhenIdle(); + + // The cut turn keeps the finished call; the one in flight at the cut is stored, call then result, once it finished. + List transcript = [.. session.Transcript]; + Assert.Contains(transcript.SelectMany(m => m.Contents), content => content is FunctionResultContent { CallId: ParallelToolCallChatClient.FirstCallId }); + int call = transcript.FindIndex(m => m.Contents.OfType().Any(c => c.CallId == ParallelToolCallChatClient.SecondCallId)); + FunctionResultContent second = Assert.Single(transcript[call + 1].Contents.OfType()); + Assert.Equal((ParallelToolCallChatClient.SecondCallId, PartiallyAnsweredToolFactory.SecondAnswer), (second.CallId, second.Result?.ToString())); } [Fact(Timeout = 30_000)] @@ -120,7 +112,7 @@ public async Task ProseBesideAFinishedToolCall_KeepsTheProseInPlaceAndCutsTheLas // A real model routinely puts a line of prose and the tool call it announces in one // assistant message, then answers in a second step. The caller heard the prose whole and // part of the answer, so the prose stays before its call and only the answer is cut - // (owner ruling 2026-09-23). The cut names every word heard, across steps. + // (the cut names every word heard, across steps). await using InterruptionFixture fixture = InterruptionFixture.StartWithProseBesideTool(reply: "the price is fifty"); _ = await fixture.InterruptAfterFirstUpdateAsync(heard: ProseBesideToolChatClient.Prose + "the price"); @@ -144,7 +136,7 @@ public async Task ProseBesideAFinishedToolCall_KeepsTheProseInPlaceAndCutsTheLas public async Task AnInterruptAfterTheTurnEnded_AmendsTheFinishedTurn() { // No gate and no blocking client: the whole point is that the turn is already over when - // the frame lands. D28 and item 6a still hold — both vendor values pass through unchanged. + // the frame lands. Both vendor values pass through unchanged. using ScriptedChatClient reply = new("Hello", " there", " caller"); InMemoryAuditSink sink = new(); ConversationSession session = CreateSession(NoToolYaml, reply, auditSink: sink); @@ -167,8 +159,8 @@ public async Task AnInterruptAfterTheTurnEnded_AmendsTheFinishedTurn() ChatMessage assistant = Assert.Single(session.Transcript, m => m.Role == ChatRole.Assistant); Assert.Equal("Hello there", assistant.Text); - // T23: the chain is append-only, so the correction is a second event that names the first. - IReadOnlyList events = sink.EventsOf(session.ConversationId); + // The chain is append-only, so the correction is a second event that names the first. + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); AuditEvent amendment = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal(completed.EventId, amendment.AmendsEventId); @@ -180,11 +172,11 @@ public async Task AnInterruptAfterTheTurnEnded_AmendsTheFinishedTurn() // One barge-in cuts one reply once. A repeat frame amends nothing a second time. Assert.False(session.Cut(0, new TurnCut("Hello there caller", TimeSpan.FromMilliseconds(2400)))); - _ = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + _ = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); } [Fact(Timeout = 30_000)] - public void AnInterruptBeforeAnyTurnEverRan_ChangesNothingAndThrowsNothing() + public async Task AnInterruptBeforeAnyTurnEverRan_ChangesNothingAndThrowsNothing() { using ScriptedChatClient reply = new("hello"); InMemoryAuditSink sink = new(); @@ -195,12 +187,11 @@ public void AnInterruptBeforeAnyTurnEverRan_ChangesNothingAndThrowsNothing() Assert.Null(session.LastTurn); Assert.Empty(session.Transcript); Assert.DoesNotContain( - sink.EventsOf(session.ConversationId), + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); } - // Probe W04 (design section 3, row "Cut before output", G1 = Cut(turn, "", null)): a cut of the running turn - // that names nothing shown keeps its user message only. + // A cut of the running turn that names nothing shown (Cut(turn, "", null)) keeps its user message only. [Fact(Timeout = 30_000)] public async Task AnInterruptWhileAnUnheardSecondTurnRuns_CutsTheSecondTurnWithNothingShown_AndLeavesTheFirst() { @@ -264,7 +255,7 @@ public async Task AnInterruptWhileAnUnheardSecondTurnRuns_CutsTheSecondTurnWithN Assert.Equal(string.Empty, session.LastTurn.ReplyText); AuditEvent amendment = Assert.Single( - sink.EventsOf(session.ConversationId), + await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal(first.TurnIndex + 1, amendment.TurnIndex); Assert.Equal( @@ -274,7 +265,7 @@ public async Task AnInterruptWhileAnUnheardSecondTurnRuns_CutsTheSecondTurnWithN } // A barge-in that lands while the turn is still finishing itself. The reply is over, the writers - // are running, and the extractor can hold the turn for up to TurnCompletionTimeout — five whole + // are running, and the extractor can hold the turn for up to TurnFailureReasons.CompletionTimeout — five whole // seconds in which the run still looks live to Cut. [Fact(Timeout = 30_000)] @@ -299,7 +290,7 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( compiled, new GuardEvaluator(compiled.Configuration.Guards), ConversationSessionFactory.CreateExtractor(compiled, chatClients), - observers: ConversationObservers.Standard(sink, logger: null)).Create(); + hooks: BuiltInHooks.Create(sink)).Create(); using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( @@ -334,14 +325,14 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( finally { // The gate ignores cancellation, so a failed assertion above must still release it - // rather than leave the turn parked until TurnCompletionTimeout expires. + // rather than leave the turn parked until TurnFailureReasons.CompletionTimeout expires. extractor.OpenGate(); } await running.WaitAsync(bounded.Token); TurnResult? turn = session.LastTurn; - // D28: both values are the ones the relay reported, unchanged. + // Both values are the ones the relay reported, unchanged. Assert.NotNull(turn); Assert.Equal(TimeSpan.FromMilliseconds(1820), turn.Cut); Assert.Equal("Hello there", turn.ReplyText); @@ -350,8 +341,8 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( ChatMessage assistant = Assert.Single(session.Transcript, message => message.Role == ChatRole.Assistant); Assert.Equal("Hello there", assistant.Text); - // T23: the correction is a second event that names the first, and never an edit of it. - IReadOnlyList events = sink.EventsOf(session.ConversationId); + // The correction is a second event that names the first, and never an edit of it. + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); AuditEvent amendment = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal(completed.EventId, amendment.AmendsEventId); @@ -363,7 +354,7 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( // One barge-in cuts one reply once. The turn is no longer amendable, so a repeat says so. Assert.False(session.Cut(0, new TurnCut("Hello there caller", TimeSpan.FromMilliseconds(2400)))); - _ = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + _ = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); } // The deadline that bounds the work after the reply. @@ -379,7 +370,7 @@ public async Task AnExtractorThatNeverReturns_DoesNotHoldTheConversationOpenFore TurnResult turn = await fixture.RunTurnAsync("hi").WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); Assert.NotNull(turn); - Assert.Equal(ConversationSession.ExtractionTimedOutReason, fixture.LastExtractionFailure); + Assert.Equal(TurnFailureReasons.ExtractionTimedOut, fixture.LastExtractionFailure); } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs index 6b2d025f..0f58f2c6 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs @@ -1,7 +1,7 @@ using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Domain; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs index 4b7f0da1..515e728f 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs @@ -2,7 +2,9 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Transcript; using AgentCore.Domain; @@ -11,6 +13,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { @@ -33,16 +36,18 @@ public async Task AModelThatIsDown_BeforeAnyToolCall_IsReportedAsARunFault() { using DownModelChatClient model = new(); InMemoryAuditSink sink = new(); - RecordingObserver observed = new(); + RecordingHook observed = new(); using RecordingLoggerFactory logs = new(); ConversationSession session = Build(model, sink, observed, logs.CreateLogger("session")); TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); + Assert.Equal("the turn's run faulted, so it spoke the fallback. 503 from the model endpoint", turn.Failure); - Assert.DoesNotContain(sink.EventsOf(ConversationId), item => item.Kind == AuditEventKind.ToolFailed); - Assert.Contains(observed.Events, item => item.Kind == ConversationEventKind.RunFaulted); - Assert.DoesNotContain(observed.Events, item => item.Kind == ConversationEventKind.EmptyReply); + Assert.DoesNotContain(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ToolFailed); + TurnCompleted completed = Assert.Single(observed.Of()); + Assert.Equal((TurnOutcome.Fallback, false), (completed.Outcome, completed.FailedInTool)); Assert.Empty(logs.Of(ToolBudgetSpentEvent)); CapturedLine line = Assert.Single(logs.Of(TurnRunFaultedEvent)); Assert.Equal(LogLevel.Error, line.Level); @@ -50,7 +55,7 @@ public async Task AModelThatIsDown_BeforeAnyToolCall_IsReportedAsARunFault() } private static ConversationSession Build( - IChatClient model, InMemoryAuditSink sink, RecordingObserver observed, ILogger logger) + IChatClient model, InMemoryAuditSink sink, RecordingHook observed, ILogger logger) { CompiledAgent compiled = ConfigurationCompiler.CompileAll( ConfigurationLoader.LoadYaml(InterruptionSessions.NoToolYaml), @@ -61,7 +66,7 @@ private static ConversationSession Build( new GuardEvaluator(compiled.Configuration.Guards), extractor: null, logger: logger, - observers: ConversationObservers.Standard(sink, logger, [observed])).Create(ConversationId); + hooks: [.. BuiltInHooks.Create(sink, logger), observed]).Create(ConversationId); } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs new file mode 100644 index 00000000..a6fba153 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs @@ -0,0 +1,250 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Harness; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Two approval-required calls in one model round: one message may answer both, answers given one by one + /// survive a reload of the conversation between them, and new words instead of an answer refuse what is open. + /// + public sealed class ConversationSessionParallelApprovalTests + { + /// What Microsoft.Extensions.AI tells the model about a call a person refused. + private const string Rejected = "Tool call invocation rejected."; + + private const string MovedOn = Rejected + " the user moved on without answering."; + + private const string TwoToolsYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: send_email, kind: builtin, uses: test.send_email, description: "Send an email." } + - { id: send_sms, kind: builtin, uses: test.send_sms, description: "Send a text message." } + agents: + items: + - id: only + instructions: "send both" + tools: [ send_email, send_sms ] + entries: + main: + agent: only + """; + + private const string NoToolsYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - id: only + instructions: "send both" + entries: + main: + agent: only + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task OneMessageAnsweringBothRequests_RunsTheApprovedToolAndReplies() + { + Dictionary ran = Ran(); + ConversationSession session = Session(TwoToolsYaml, ran, new InMemoryConversationStore()); + + TurnResult asked = await session.RunTurnAsync("send both", Ct); + Assert.Equal(2, asked.Approvals.Count); + ChatMessage both = new(ChatRole.User, [ + .. session.TryCreateApprovalAnswer(RequestFor(asked, "send_email"), approved: true)!.Contents, + .. session.TryCreateApprovalAnswer(RequestFor(asked, "send_sms"), approved: false)!.Contents, + ]); + TurnResult replied = await session.RunTurnMessageAsync(both, Ct); + + Assert.Null(replied.Failure); + Assert.Empty(replied.Approvals); + Assert.Equal(1, ran["send_email"]); + Assert.Equal(0, ran["send_sms"]); + Assert.Equal($"call_1=sent:send_email\ncall_2={Rejected}", replied.ReplyText); + } + + // With an auto: block MAF's approval layer asks one request at a time and queues the other in its own state. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AnswersGivenOneByOne_SurviveAReloadBetweenThem(bool underAnAutoRule) + { + await AnswerOneByOneAcrossAReloadAsync(underAnAutoRule ? WithAutoRule(TwoToolsYaml) : TwoToolsYaml, hooks: null, expectedFirst: underAnAutoRule ? 1 : 2); + } + + // A tool a hook adds at run time is no declared tool, yet it asks the same way. + [Fact] + public async Task ToolsAHookAddsAtRunTime_AreAnsweredOneByOneAcrossAReload() + { + Dictionary ran = Ran(); + await AnswerOneByOneAcrossAReloadAsync(NoToolsYaml, [new AddingHook(ran)], expectedFirst: 2, ran); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NewWordsWhileBothRequestsAreOpen_RefuseThemAndRunTheWords(bool underAnAutoRule) + { + Dictionary ran = Ran(); + using RequestCapturingChatClient model = new(new TwoGatedCallsChatClient()); + ConversationSession session = Session(underAnAutoRule ? WithAutoRule(TwoToolsYaml) : TwoToolsYaml, ran, new InMemoryConversationStore(), model: model); + + _ = await session.RunTurnAsync("send both", Ct); + TurnResult moved = await session.RunTurnAsync("never mind", Ct); + + Assert.Null(moved.Failure); + Assert.Empty(moved.Approvals); + Assert.Equal(0, ran["send_email"] + ran["send_sms"]); + Assert.Equal($"call_1={MovedOn}\ncall_2={MovedOn}", moved.ReplyText); + List seen = [.. model.Requests[^1]]; + int words = seen.FindLastIndex(message => message.Role == ChatRole.User); + Assert.Equal("never mind", seen[words].Text); + Assert.True(seen.FindLastIndex(message => message.Role == ChatRole.Tool) < words); + Assert.Null((await session.RunTurnAsync("thanks", Ct)).Failure); + } + + // An approval already given still counts when the caller moves on before answering the rest. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NewWordsAfterOneAnswer_KeepThatAnswerAndRefuseTheRest(bool underAnAutoRule) + { + Dictionary ran = Ran(); + RecordingHook hook = new(); + ConversationSession session = Session(underAnAutoRule ? WithAutoRule(TwoToolsYaml) : TwoToolsYaml, ran, new InMemoryConversationStore(), hooks: [hook]); + + TurnResult asked = await session.RunTurnAsync("send both", Ct); + PendingApproval email = asked.Approvals.Single(approval => approval.ToolName == "send_email"); + _ = await session.RunTurnMessageAsync(session.TryCreateApprovalAnswer(email.RequestId, approved: true)!, Ct); + TurnResult moved = await session.RunTurnAsync("never mind", Ct); + + Assert.Null(moved.Failure); + Assert.Empty(moved.Approvals); + Assert.Equal(1, ran["send_email"]); + Assert.Equal(0, ran["send_sms"]); + Assert.Equal($"call_1=sent:send_email\ncall_2={MovedOn}", moved.ReplyText); + _ = await hook.WaitForAsync(completed => completed.Scope.TurnIndex == 2).WaitAsync(TimeSpan.FromSeconds(10), Ct); + Assert.Equal( + [("send_email", ApprovalState.Approved), ("send_sms", ApprovalState.Denied)], + hook.Of().Where(changed => changed.State != ApprovalState.Asked).Select(changed => (changed.ToolName, changed.State))); + } + + // A delegated agent runs without streaming: the same message reaches the model past MAF's queue. + [Fact] + public async Task UnderAnAutoRule_ARunThatDoesNotStream_TakesTheRefusalsAndTheWordsPastTheQueue() + { + Dictionary ran = Ran(); + AIAgent agent = HookSessions.Compile( + WithAutoRule(TwoToolsYaml), + new TwoGatedCallsChatClient(), + tools: declared => declared.Uses is "test.send_email" or "test.send_sms" ? Gated(declared.Id, ran) : null)["main"].Agents["only"]; + AgentSession session = await agent.CreateSessionAsync(Ct); + + AgentResponse asked = await agent.RunAsync("send both", session, cancellationToken: Ct); + ToolApprovalRequestContent shown = Assert.Single(asked.Messages.SelectMany(message => message.Contents).OfType()); + ToolApprovalRequestContent queued = Assert.Single(PendingApprovalQueue.Requests(session), request => request.RequestId != shown.RequestId); + AgentResponse replied = await agent.RunAsync( + TurnApprovalAnswers.MovedOn(new ChatMessage(ChatRole.User, "never mind"), [shown, queued]), session, cancellationToken: Ct); + + Assert.Equal(0, ran["send_email"] + ran["send_sms"]); + Assert.Equal($"call_1={MovedOn}\ncall_2={MovedOn}", replied.Text); + } + + private static async Task AnswerOneByOneAcrossAReloadAsync( + string yaml, IReadOnlyList? hooks, int expectedFirst, Dictionary? ran = null) + { + ran ??= Ran(); + InMemoryConversationStore store = new(); + + ConversationSession first = Session(yaml, ran, store, hooks: hooks); + TurnResult asked = await first.RunTurnAsync("send both", Ct); + Assert.Equal(expectedFirst, asked.Approvals.Count); + string shown = asked.Approvals[0].ToolName; + TurnResult waiting = await first.RunTurnMessageAsync( + first.TryCreateApprovalAnswer(asked.Approvals[0].RequestId, approved: shown == "send_email")!, Ct); + await first.FlushTranscriptAsync(); + + PendingApproval rest = Assert.Single(waiting.Approvals); + Assert.NotEqual(shown, rest.ToolName); + + ConversationSession resumed = Session(yaml, ran, store, first.ConversationId, hooks); + ChatMessage? answer = await resumed.TryCreateApprovalAnswerAsync(rest.RequestId, approved: rest.ToolName == "send_email", Ct); + Assert.NotNull(answer); + TurnResult replied = await resumed.RunTurnMessageAsync(answer, Ct); + + Assert.Null(replied.Failure); + Assert.Empty(replied.Approvals); + Assert.Equal(1, ran["send_email"]); + Assert.Equal(0, ran["send_sms"]); + Assert.Equal($"call_1=sent:send_email\ncall_2={Rejected}", replied.ReplyText); + } + + private static string WithAutoRule(string yaml) + { + return yaml.Replace("tools: [ send_email, send_sms ]", "tools: [ send_email, send_sms ]\n approval: { auto: [ nothing_matches ] }", StringComparison.Ordinal); + } + + private static Dictionary Ran() + { + return new(StringComparer.Ordinal) { ["send_email"] = 0, ["send_sms"] = 0 }; + } + + private static string RequestFor(TurnResult asked, string tool) + { + return asked.Approvals.Single(approval => approval.ToolName == tool).RequestId; + } + + private static ConversationSession Session( + string yaml, + Dictionary ran, + InMemoryConversationStore store, + string? conversationId = null, + IReadOnlyList? hooks = null, + IChatClient? model = null) + { + return HookSessions.Create( + yaml, + model ?? new TwoGatedCallsChatClient(), + hooks, + store, + declared => declared.Uses is "test.send_email" or "test.send_sms" ? Gated(declared.Id, ran) : null, + conversationId); + } + + private static ApprovalRequiredAIFunction Gated(string name, Dictionary ran) + { + return new(AIFunctionFactory.Create( + (string to) => + { + ran[name]++; + return $"sent:{name}"; + }, + name, + name)); + } + + /// Adds the two approval-required tools before the run, as a host's run-time tools would arrive. + private sealed class AddingHook(Dictionary ran) : AgentHook + { + public override ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) + { + gate.AddTools([Gated("send_email", ran), Gated("send_sms", ran)]); + return ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs index 65d9e66d..3d3dafc5 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs @@ -1,13 +1,13 @@ using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime { - /// D4: the inbound port. + /// The inbound port. public sealed class ConversationSessionPortTests { [Fact] diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionProbeTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionProbeTests.cs index 3cdf5f4b..1e5923d4 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionProbeTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionProbeTests.cs @@ -3,24 +3,24 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.State; using AgentCore.Domain.Knowledge; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { /// - /// §8's probe, driven through real turns rather than by seeding the - /// ambient: the genuinely two-turn row (K43's latch reset against K22's persistent counter) needs + /// The probe, driven through real turns rather than by seeding the + /// ambient: the genuinely two-turn row (the per-turn latch reset against the persistent ask counter) needs /// the real turn loop's own BeginTurn, which KnowledgeProbeTests's ambient-level /// tests cannot exercise. /// public sealed class ConversationSessionProbeTests { - /// One tool-mode, scoped agent over two droppable facets, so K33 never blocks the drop. + /// One tool-mode, scoped agent over two droppable facets. private const string ProbeYaml = """ apiVersion: agentcore/v1 @@ -66,7 +66,7 @@ public sealed class ConversationSessionProbeTests [Fact] public async Task TwoTurns_TheProbeAlwaysThrows_TheSecondTurnStillRunsAFreshProbeSearch() { - // K43's latch is per-turn (cleared by BeginTurn); K22's probeAsks counter is not. A second + // The latch is per-turn (cleared by BeginTurn); the probeAsks counter is not. A second // turn that attempts a genuinely new probe search -- rather than replaying turn one's stored // failure -- is what proves BeginTurn actually ran, wired through the real turn loop rather // than asserted directly against Clarifications (KnowledgeProbeTests already covers that unit @@ -104,7 +104,7 @@ public async Task TwoTurns_TheProbeAlwaysThrows_TheSecondTurnStillRunsAFreshProb /// /// A knowledge store that answers the full scope with nothing and throws for any narrowed one -- - /// the shape §8 step 4's own search takes once a facet is dropped. + /// the shape the probe's own search takes once a facet is dropped. /// private sealed class ThrowingOnNarrowedScopePort : IKnowledgeRetrievalPort { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs index b33f05c7..126b4f28 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs @@ -1,17 +1,20 @@ using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; namespace AgentCore.Application.Tests.Runtime { /// /// : a later account of what reached the user replaces the cut a turn - /// already took, while that turn is the newest one started (owner ruling E3). + /// already took, while that turn is the newest one started. /// public sealed class ConversationSessionRecutTests { @@ -45,11 +48,11 @@ public async Task ARecutWhileTheCutTurnStillRuns_CommitsTheTurnWithTheRecutText( Assert.Equal(TimeSpan.FromMilliseconds(300), session.LastTurn!.Cut); Assert.Equal(1, store.Appends); Assert.Empty(store.Rewrites); - AuditEvent interrupted = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + AuditEvent interrupted = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); Assert.Equal(AuditHash.OfText("Hel").Value, interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); } - // TurnCutSlot remarks: a recut between the seal and the commit waits for the commit, which rewrites the reply + // A recut between the seal and the commit waits for the commit, which rewrites the reply // the cut shaped. The store holds the append, so the turn stays sealed and uncommitted meanwhile. [Fact] public async Task ARecutWhileTheCutTurnsAppendIsInFlight_RewritesTheReplyOnceItLands() @@ -92,7 +95,7 @@ public async Task ARecutWhileTheCutTurnsAppendIsInFlight_RewritesTheReplyOnceItL Assert.Equal(TimeSpan.FromMilliseconds(300), session.LastTurn!.Cut); Assert.Equal( [AuditHash.OfText("Hello").Value, AuditHash.OfText("Hel").Value], - sink.EventsOf(session.ConversationId) + (await session.RowsAsync(sink)) .Where(item => item.Kind == AuditEventKind.ReplyInterrupted) .Select(item => item.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256])); } @@ -118,7 +121,7 @@ public async Task ARecutOfACommittedCutTurn_RewritesTheReply_AndAmendsTurnComple Assert.Equal(TimeSpan.FromMilliseconds(900), session.LastTurn.Cut); Assert.Equal(1, store.Appends); Assert.Equal(2, store.Rewrites.Count); - IReadOnlyList events = sink.EventsOf(session.ConversationId); + IReadOnlyList events = await session.RowsAsync(sink); AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); List amendments = [.. events.Where(item => item.Kind == AuditEventKind.ReplyInterrupted)]; Assert.Equal(2, amendments.Count); @@ -142,10 +145,10 @@ public async Task ARecutOfATurnThatTookNoCut_ReportsFalseAndChangesNothing() Assert.False(recut); Assert.Equal(["hi", "Hello there"], Texts(session.Transcript)); Assert.Empty(store.Rewrites); - Assert.DoesNotContain(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + Assert.DoesNotContain(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ReplyInterrupted); } - // Owner ruling E3: once a later turn has started, an earlier turn's reply never changes. + // Once a later turn has started, an earlier turn's reply never changes. [Fact] public async Task ARecutOfACutTurnOnceTheNextTurnStarted_ReportsFalseAndChangesNothing() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs index ef16b84e..e339cd98 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs @@ -1,7 +1,7 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.State; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime @@ -24,7 +24,7 @@ public async Task TheReminder_ReachesTheReplyAgentAndLeavesTheCallersMessageAlon Assert.Contains( "the machine model and the serial number", reply.SystemText(0), StringComparison.Ordinal); - // The caller's utterance is the caller's. It goes to the model, to store 1 and to the + // The caller's utterance is the caller's. It goes to the model, to the message store and to the // extractor as it was spoken. Assert.Equal("hi", reply.LastUserText(0)); Assert.Equal("hi", session.Transcript[0].Text); @@ -70,7 +70,7 @@ public async Task TheReminder_ReachesAStreamingTurnToo() public async Task TheReminder_NeverAsksTheCallerForAnInferredFlag() { // config/local.yaml ships this shape: one boolean the extractor infers from the turn, read by - // the exit guard of the talking stage, carrying a default. Section 8.3 reminds on a slot that + // the exit guard of the talking stage, carrying a default. The reminder fires on a slot that // is "still null", and a default is never null, so nothing is owed and the caller is not asked. using SequencedChatClient reply = new("hello there."); using SequencedChatClient fill = new(StayingNull); diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs index b48f35c4..23f75947 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs @@ -1,9 +1,9 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime @@ -95,7 +95,7 @@ public async Task ASecondTurn_WaitsForTheFirstOne_ThenRunsAfterIt() Assert.Equal(2, session.State.TurnIndex); } - // Section 8.6: the update stream carries content only. + // The update stream carries content only. [Fact] public async Task Streaming_DropsEveryUpdateThatCarriesNoContent() { @@ -110,7 +110,7 @@ public async Task Streaming_DropsEveryUpdateThatCarriesNoContent() // AsAIAgent() yields 47 updates for 40 text fragments, and seven carry no content. The seam // filters them once, so no host writes the filter again. The trailing update is the turn's - // committed ids (design section 6, step E5): it carries content but no text. + // committed ids: it carries content but no text. Assert.Equal(6, reply.Yielded); Assert.Equal( ["hello", " there."], diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs index cc962a52..b53382df 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs @@ -2,11 +2,11 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs index 06bacad2..b5438c86 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs @@ -1,13 +1,14 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime { - /// Section 8.7, sixth row: the 4th consecutive tool failure. + /// The 4th consecutive tool failure. public sealed class ConversationSessionToolFailureTests { [Fact] @@ -24,7 +25,7 @@ public async Task TheFourthConsecutiveToolFailure_EndsTheTurnAndNeverKillsTheCon Assert.Equal(4, tools.Calls); Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); Assert.NotNull(turn.Failure); - Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); + Assert.StartsWith(TurnFailureReasons.ToolFailure, turn.Failure, StringComparison.Ordinal); Assert.Contains(ThrowingToolBuilder.Message, turn.Failure, StringComparison.Ordinal); // The writers ran in their fixed order, and the machine picked the stage of the next turn. @@ -32,7 +33,7 @@ public async Task TheFourthConsecutiveToolFailure_EndsTheTurnAndNeverKillsTheCon Assert.Equal("greeting", turn.StageAfter); Assert.False(session.IsComplete); - // Engine design section 3, W07: a tool fault keeps the user message, the three rounds whose + // A tool fault keeps the user message, the three rounds whose // result arrived, and the fallback. The 4th call never got a result, so it is dropped. Assert.Equal("where is my order", session.Transcript[0].Text); Assert.Equal(3, session.Transcript.SelectMany(message => message.Contents).OfType().Count()); @@ -57,7 +58,7 @@ public async Task ARealDeclaredToolThatCannotReachItsEndpoint_EndsTheTurnPerRowS Assert.Equal(4, tools.Calls); Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); Assert.NotNull(turn.Failure); - Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); + Assert.StartsWith(TurnFailureReasons.ToolFailure, turn.Failure, StringComparison.Ordinal); Assert.Contains(UnreachableEndpointToolBuilder.Message, turn.Failure, StringComparison.Ordinal); // The writers ran in their fixed order, the machine picked the stage of the next turn, and @@ -120,7 +121,7 @@ public async Task Streaming_EndsTheTurnWhenTheFourthConsecutiveToolFailureThrows Assert.Equal(4, tools.Calls); Assert.NotNull(session.LastTurn); Assert.Equal(ConversationSession.FallbackReply, session.LastTurn.ReplyText); - Assert.StartsWith(ConversationSession.ToolFailureReason, session.LastTurn.Failure!, StringComparison.Ordinal); + Assert.StartsWith(TurnFailureReasons.ToolFailure, session.LastTurn.Failure!, StringComparison.Ordinal); Assert.Equal(1, session.State.TurnIndex); } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs index 585193e6..eb1be182 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs @@ -1,7 +1,7 @@ -using AgentCore.Application.Runtime; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs index ec8a679c..c43b3ff2 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs @@ -1,8 +1,10 @@ using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; namespace AgentCore.Application.Tests.Runtime @@ -19,7 +21,7 @@ public sealed class ConversationSessionTurnHistoryTests private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Probe P7e (docs/probes/voice-engine/engine/REPORT.md, F1): the next turn's start reads the store while the + // The next turn's start reads the store while the // turn before it is cut and commits. The cut turn ends only once the store answered its append // (ConversationTurnCommit.RefusedAsync), so the append lands before that read returns. [Fact] @@ -55,7 +57,7 @@ public async Task NextTurnStartsWhileThePreviousTurnCommits_ItsModelReadsThePrev Assert.Equal(["q0", "q1", "second reply"], Texts(session.Transcript)); } - // Probe P7a/P7c, the other order: the store read begun before the turn was admitted misses the turn that + // The other order: the store read begun before the turn was admitted misses the turn that // committed meanwhile, since the session's words moved under it, and the store also holds a row written from // outside any turn. The admitted turn reads both. [Fact] @@ -95,7 +97,7 @@ public async Task StoreReadBeforeAdmissionMissesTheTurnThatCommitted_TheAdmitted Texts(model.Requests[2].Where(message => message.Role != ChatRole.System))); } - // Probe F1, second cause: a start that gives up waiting for a running turn must not re-read the store into the + // A start that gives up waiting for a running turn must not re-read the store into the // session. The next admitted turn still catches up with what was written from outside. [Fact] public async Task AStartAbandonedWhileATurnRuns_ChangesNoWords_AndTheNextTurnCatchesUp() diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs index f623580f..2f13e27c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs @@ -3,19 +3,21 @@ using AgentCore.Application.Audit.Memory; using AgentCore.Application.Conversation; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; namespace AgentCore.Application.Tests.Runtime { /// /// The a started turn hands back: how its disposal and its one read hold and free the - /// conversation (owner ruling 2026-09-23). + /// conversation. /// public sealed class ConversationSessionTurnRunTests { @@ -42,7 +44,7 @@ public async Task DisposingAnUnreadRunTwice_LeavesTheTurnStartedSinceHoldingTheC () => session.StartTurnAsync(new ChatMessage(ChatRole.User, "three"), origin: null, giveUp.Token)); } - // TurnRun remarks: a run disposed unread commits nothing, and its reply can no longer be read. + // A run disposed unread commits nothing, and its reply can no longer be read. [Fact] public async Task ARunDisposedUnread_RefusesToBeRead_AndNeverCallsTheModel() { @@ -67,7 +69,7 @@ public async Task ARunDisposedUnread_RefusesToBeRead_AndNeverCallsTheModel() } // BeginTurn opens the span before the caller asks for the reply, so a run disposed unread still closes it, - // with the outcome and the duration a stopped turn gets (audit finding F2, docs/probes/r2/audit/AUDIT.md). + // with the outcome and the duration a stopped turn gets. [Fact] public async Task ARunDisposedUnread_StillEndsItsSpan_WithAnInterruptedOutcomeAndADuration() { @@ -119,7 +121,7 @@ public async Task ARunDisposedUnread_StillEndsItsSpan_WithAnInterruptedOutcomeAn } } - // Owner ruling: a turn that is refused or dropped for any reason leaves a log line and an audit event. + // A turn that is refused or dropped for any reason leaves a log line and an audit event. [Fact] public async Task ARunDisposedUnread_LeavesATurnRefusedThatSaysItWasDropped() { @@ -131,7 +133,7 @@ public async Task ARunDisposedUnread_LeavesATurnRefusedThatSaysItWasDropped() await run.DisposeAsync(); // Assert - AuditEvent refused = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnRefused); + AuditEvent refused = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnRefused); Assert.Equal((0, "dropped"), (refused.TurnIndex, refused.Payload[AuditPayloadKeys.RefusedReason])); } @@ -151,7 +153,7 @@ public async Task ATurnStartedWhileAnotherRunsPastTheWaitLimit_IsRefusedAsBusy() // Assert _ = await Assert.ThrowsAsync(() => starting); - AuditEvent refused = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnRefused); + AuditEvent refused = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnRefused); Assert.Equal(((int?)null, "busy"), (refused.TurnIndex, refused.Payload[AuditPayloadKeys.RefusedReason])); } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWorkspaceTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWorkspaceTests.cs index de37dec5..04051ce1 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWorkspaceTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWorkspaceTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; @@ -12,6 +11,7 @@ using Xunit; using AgentCore.Domain; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { @@ -19,9 +19,6 @@ namespace AgentCore.Application.Tests.Runtime /// The conversation workspace as a host sees it: bound (or not) through , /// deleted when the conversation ends, and visible to a bound tool through . /// - /// - /// Every test here runs offline. There is no network conversation and no API key anywhere in this file. - /// public sealed class ConversationSessionWorkspaceTests : IDisposable { private const string SimpleYaml = diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs index 30adea0e..00b8e5a2 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs @@ -1,9 +1,9 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.State; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime @@ -137,7 +137,7 @@ public async Task AFailedExtraction_DoesNotDropTheTurn() TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - // Section 8.7: the extractor never drops a conversation. The turn ends, and it carries the reason. + // The extractor never drops a conversation. The turn ends, and it carries the reason. Assert.NotNull(turn.ExtractionFailure); Assert.Equal("hello there.", turn.ReplyText); Assert.Equal("greeting", turn.StageAfter); diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs index 30cd4fcb..4d33e54c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs @@ -1,21 +1,22 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// /// The Cut rule over the real : a cut, a host cancel and an abandoned stream - /// each end in the turn's one append (docs/handoff/2026-09-22-maf-native-engine-design.md, section 3). + /// each end in the turn's one append. /// public sealed class ConversationTurnAgentCutTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Probe W03: a cut mid-text keeps the user and the shown text, in one append. + // A cut mid-text keeps the user and the shown text, in one append. [Fact] public async Task CutMidText_KeepsTheUserAndTheShownText_InOneAppend() { @@ -38,7 +39,7 @@ public async Task CutMidText_KeepsTheUserAndTheShownText_InOneAppend() Assert.Empty(h.Store.Rewrites); } - // Probe W04 (G1): a cut before any output keeps the user message only, and the turn before stays untouched. + // A cut before any output keeps the user message only, and the turn before stays untouched. [Fact] public async Task CutBeforeOutput_KeepsTheUserOnly_AndLeavesThePreviousTurnUntouched() { @@ -62,7 +63,7 @@ public async Task CutBeforeOutput_KeepsTheUserOnly_AndLeavesThePreviousTurnUntou Assert.Equal(["one", "first", "two"], Texts(h.History.Read(h.Session))); } - // Probe W05: a host cancel keeps the user and everything yielded, in one append the hook never fed. + // A host cancel keeps the user and everything yielded, in one append the hook never fed. [Fact] public async Task HostCancel_KeepsTheUserAndEverythingYielded_InOneAppend() { @@ -87,7 +88,7 @@ public async Task HostCancel_KeepsTheUserAndEverythingYielded_InOneAppend() Assert.Equal(["hi", "Hel"], Texts(h.Store.Rows.Select(row => row.Content))); } - // Probe W06: a reader that abandons the stream still gets the turn committed, from the layer's finally. + // A reader that abandons the stream still gets the turn committed, from the layer's finally. [Fact] public async Task ReaderAbandonsTheStream_TheLayerCommitsTheUserAndTheYieldedText_InOneAppend() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs index cd917faa..5d564b95 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Evaluation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Evaluation.Fakes; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; @@ -7,11 +6,14 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Agents; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// - /// The design probes' harness over the real types: one with a tool, the real + /// A harness over the real types: one with a tool, the real /// fallback and moderation layers, and outermost, all on one real provider. /// internal sealed class ConversationTurnAgentHarness diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs index 9d468d52..e223e91c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; @@ -6,6 +5,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { @@ -17,7 +17,7 @@ public sealed class ConversationTurnAgentSealTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - // The layer's remarks: a nested run shares its parent's turn but never seals it. + // A nested run shares its parent's turn but never seals it. [Fact] public async Task ANestedRun_PassesStraightThrough_AndLeavesTheParentTurnUnsealed() { @@ -41,7 +41,7 @@ public async Task ANestedRun_PassesStraightThrough_AndLeavesTheParentTurnUnseale Assert.True(o.Turn.Slot.TrySeal(out _)); } - // Section 8.7, row six: a fault above every fallback still ends in a turn sealed with that fault, and the + // A fault above every fallback still ends in a turn sealed with that fault, and the // reader's stream ends without it. [Fact] public async Task AFaultAboveEveryFallback_IsSealedAsAFault_AndTheReaderSeesNoException() @@ -65,7 +65,7 @@ public async Task AFaultAboveEveryFallback_IsSealedAsAFault_AndTheReaderSeesNoEx Assert.Equal(["hi", "Hel", "fallback"], Texts(o.Store.Rows.Select(row => row.Content))); } - // Design row W06: a reader that stops reading disposes the layer's stream, and the layer disposes the run + // A reader that stops reading disposes the layer's stream, and the layer disposes the run // beneath it rather than leaving it suspended. [Fact] public async Task AReaderThatStopsReading_ClosesTheRunBeneathTheLayer() diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs index b9574290..fd40eead 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Evaluation.Fakes; using AgentCore.Application.Tests.Fakes; @@ -8,19 +7,19 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// - /// as the one trigger of a turn's durable write: the design's revision 2 - /// probes, ported over the real layer, provider, fallback and moderation layers - /// (docs/handoff/2026-09-22-maf-native-engine-design.md, section 5a). + /// as the one trigger of a turn's durable write, tested + /// over the real layer, provider, fallback and moderation layers. /// public sealed class ConversationTurnAgentTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Probe W01: the hook stages, the stage machine runs, then one write holds the words and the fresh state. + // The hook stages, the stage machine runs, then one write holds the words and the fresh state. [Fact] public async Task CompletedTurn_HookStagesThenCompleteThenOneAppend_WithTheStateAfterTheAdvance() { @@ -40,7 +39,7 @@ public async Task CompletedTurn_HookStagesThenCompleteThenOneAppend_WithTheState Assert.Empty(h.History.Staged(h.Session)); } - // Probe W02: a buffered caller gets the reply, and the turn is the same single write. + // A buffered caller gets the reply, and the turn is the same single write. [Fact] public async Task BufferedCaller_GetsTheReply_AndTheTurnIsOneAppendFromTheHook() { @@ -61,7 +60,7 @@ public async Task BufferedCaller_GetsTheReply_AndTheTurnIsOneAppendFromTheHook() Assert.Equal(["hi", "hello"], Texts(h.Store.Rows.Select(row => row.Content))); } - // Probe W07: a tool fault keeps the user, the finished pair and the fallback, in one write the hook never fed. + // A tool fault keeps the user, the finished pair and the fallback, in one write the hook never fed. [Fact] public async Task ToolFault_KeepsTheUserTheFinishedPairAndTheFallback_InOneAppend() { @@ -78,7 +77,7 @@ public async Task ToolFault_KeepsTheUserTheFinishedPairAndTheFallback_InOneAppen Assert.Equal("fallback", h.Store.Rows[^1].Content.Text); } - // Probe W08: a moderation refusal keeps the user and the refusal in one write, and the model is never called. + // A moderation refusal keeps the user and the refusal in one write, and the model is never called. [Fact] public async Task ModerationRefusal_KeepsTheUserAndTheRefusal_InOneAppend() { @@ -95,7 +94,7 @@ public async Task ModerationRefusal_KeepsTheUserAndTheRefusal_InOneAppend() Assert.Equal(0, model.Calls); } - // Probe W09: the hook stages the empty reply and never sees the fallback; the seal writes the fallback. + // The hook stages the empty reply and never sees the fallback; the seal writes the fallback. [Fact] public async Task EmptyReply_HookStagesNoFallback_TheSealWritesIt_InOneAppend() { @@ -112,7 +111,7 @@ public async Task EmptyReply_HookStagesNoFallback_TheSealWritesIt_InOneAppend() Assert.Equal(["hi", "fallback"], Texts(h.Store.Rows.Select(row => row.Content))); } - // Probe G2: a graph's output reaches the conversation through the layer; the node sessions write nothing. + // A graph's output reaches the conversation through the layer; the node sessions write nothing. [Fact] public async Task GraphRow_TheOutputReachesTheConversationThroughTheLayer_TheNodesWriteNothing() { @@ -140,7 +139,7 @@ public async Task GraphRow_TheOutputReachesTheConversationThroughTheLayer_TheNod Assert.Equal(["hi", "two"], Texts(h.Store.Rows.Select(row => row.Content))); } - // Probe G3: the committed ids reach a streaming caller on the turn's last update. + // The committed ids reach a streaming caller on the turn's last update. [Fact] public async Task StreamingCaller_GetsTheCommittedIds_OnTheLastUpdate() { @@ -156,7 +155,7 @@ public async Task StreamingCaller_GetsTheCommittedIds_OnTheLastUpdate() Assert.Equal(h.Store.Rows[^1].MessageId, committed.ReplyMessageId); } - // Probe G5: an edit is a provider command the layer triggers: the truncate, then one append, and the model + // An edit is a provider command the layer triggers: the truncate, then one append, and the model // no longer reads the withdrawn words. [Fact] public async Task Edit_TheLayerTruncatesThenAppendsOnce_AndTheModelNoLongerReadsTheWithdrawnWords() diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs index d1937cfd..9f36337f 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs @@ -1,8 +1,8 @@ using System.Diagnostics; using System.Reflection; -using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs index da6f3e90..6c6ad805 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs @@ -1,7 +1,7 @@ -using AgentCore.Application.Runtime; using AgentCore.TestSupport; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceToolGraceTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceToolGraceTests.cs new file mode 100644 index 00000000..c8ebab20 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceToolGraceTests.cs @@ -0,0 +1,174 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The workspace outlives the end while a tool still runs in it: it is deleted once the tool ends, or once the end + /// backstop stopped it later. + /// + public sealed class ConversationWorkspaceToolGraceTests : IDisposable + { + private static readonly DateTimeOffset Start = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private readonly string _root = Path.Combine(Path.GetTempPath(), "agentcore-grace-" + Guid.NewGuid().ToString("N")); + + private readonly FakeTimeProvider _time = new(Start); + + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private string? _workspace; + + private bool _ignoresStop; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact(Timeout = 20_000)] + public async Task AToolInItsGraceKeepsItsFolderAndTheFolderGoesOnceTheToolEnds() + { + ConversationSession session = Create(); + Task reading = await StartToolTurnAsync(session); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await _time.WaitForTimersAsync(Start + ConversationEnding.ToolGrace, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + _time.Advance(ConversationEnding.ToolGrace - TimeSpan.FromSeconds(1)); + Assert.True(Directory.Exists(_workspace)); + + _release.SetResult(); + await EndedAsync(reading); + await session.Lifetime.Cleanup.Pending.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + Assert.Contains(session.Transcript.SelectMany(message => message.Contents).OfType(), result => result.Result?.ToString() == "written"); + Assert.False(Directory.Exists(_workspace)); + } + + // The backstop bounds the wait even for a tool that ignores its token. + [Fact(Timeout = 20_000)] + public async Task AToolStillRunningWhenTheBackstopFiresLosesItsFolderThen() + { + _ignoresStop = true; + ConversationSession session = Create(); + Task reading = await StartToolTurnAsync(session); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await _time.WaitForTimersAsync(Start + ConversationEnding.ToolGrace, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + _time.Advance(ConversationEnding.ToolGrace - TimeSpan.FromSeconds(1)); + Assert.True(Directory.Exists(_workspace)); + + _time.Advance(TimeSpan.FromSeconds(1)); + await session.Lifetime.Cleanup.Pending.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + Assert.False(Directory.Exists(_workspace)); + _release.SetResult(); + await EndedAsync(reading); + } + + // A cut turn ended at once and left its tool running: a dispose, after an end or not, waits for that tool. + [Theory(Timeout = 20_000)] + [InlineData(true)] + [InlineData(false)] + public async Task ADisposeAfterACutWaitsForTheCutTurnsToolBeforeItTearsDown(bool ended) + { + ConversationSession session = Create(); + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "Max speed of the F63?"), origin: null, Ct); + Task reading = ReadAllAsync(run); + await _entered.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await EndedAsync(reading); + + if (ended) + { + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + } + + Task disposing = session.DisposeAsync().AsTask(); + await _time.WaitForTimersAsync(Start + ConversationEnding.ToolGrace, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + Assert.True(Directory.Exists(_workspace)); + Assert.False(disposing.IsCompleted); + + _release.SetResult(); + await disposing.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + Assert.Equal(!ended, Directory.Exists(_workspace)); + } + + private ConversationSession Create() + { + CompiledAgent compiled = HookSessions.Compile( + HookSessions.ToolAgentYaml, + new NewestWordsToolChatClient("F63"), + tools: declared => AIFunctionFactory.Create(WriteAsync, declared.Id, declared.Description), + time: _time)["main"]; + ConversationSession session = new ConversationSessionFactory( + compiled, new GuardEvaluator(compiled.Configuration.Guards), timeProvider: _time, workspaceRoot: _root).Create("conversation-1"); + _workspace = session.Workspace; + return session; + } + + private async Task StartToolTurnAsync(ConversationSession session) + { + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "Max speed of the F63?"), origin: null, Ct); + Task reading = ReadAllAsync(run); + await _entered.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + return reading; + } + + private async Task WriteAsync(CancellationToken cancellationToken) + { + _ = _entered.TrySetResult(); + await _release.Task.WaitAsync(_ignoresStop ? CancellationToken.None : cancellationToken); + + if (!_ignoresStop) + { + await File.WriteAllTextAsync(Path.Combine(_workspace!, "out.txt"), "x", cancellationToken); + } + + return "written"; + } + + private static Task ReadAllAsync(TurnRun run) + { + return Task.Run( + async () => + { + await using (run) + { + await foreach (ChatResponseUpdate _ in run.Updates.WithCancellation(Ct)) + { + } + } + }, + Ct); + } + + private static async Task EndedAsync(Task reading) + { + try + { + await reading.WaitAsync(TimeSpan.FromSeconds(5), Ct); + } + catch (OperationCanceledException) + { + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs b/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs new file mode 100644 index 00000000..573b16ac --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs @@ -0,0 +1,253 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Runtime +{ + // A cut or withdrawn turn frees the turn slot at once; its started tool runs on in the background, and its + // finished call and result reach the history of the next turn that starts after it, as in LiveKit. + public sealed class CutTurnBackgroundToolTests + { + private const string Question = "Max speed of the F63?"; + + private const string Next = "hello, are you there?"; + + private const string Answer = "ok"; + + private const string EndsAfterTheSecondTurnYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + guards: + afterSecond: { ">=": [ { var: turnIndex }, 2 ] } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "quote the price", tools: [ price_lookup ] } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: afterSecond } ] } + - { id: done, agent: only, terminal: true } + """; + + private static readonly DateTimeOffset Start = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 20_000)] + public async Task TheNextTurnAfterACutIsNotRefusedWhileTheCutTurnsToolStillRuns() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + ConversationSession session = Create(new NewestWordsToolChatClient("F63", Answer), tool, time: time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await reading.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + TurnResult next = await session.RunTurnAsync(Next, Ct).WaitAsync(TimeSpan.FromSeconds(5), Ct); + + Assert.Equal((Answer, 0), (next.ReplyText, tool.Finished)); + tool.Release.SetResult(); + await session.ToolRuns.WhenIdle(); + } + + // Order (a), the tool finishing before the next turn starts, is RunningToolInterruptTests' + // ACutWhileAToolRunsLetsItFinishAndKeepsItsCallAndResult. + + // The finished call and result are stored as soon as the tool ends: a reload with no turn in between still has + // them, a refused next turn does not lose them, and the turn after stores them no second time. + [Theory(Timeout = 20_000)] + [InlineData(false)] + [InlineData(true)] + public async Task AToolACutTurnLeftRunningIsStoredTheMomentItFinishes(bool nextTurnRefused) + { + GatedTool tool = new(); + RefusingStore store = new(); + NewestWordsToolChatClient model = new("F63", Answer); + ConversationSession session = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + store: store, + tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id), + conversationId: "conversation-1"); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await reading.WaitAsync(TimeSpan.FromSeconds(5), Ct); + tool.Release.SetResult(); + await session.ToolRuns.WhenIdle(); + store.Refuses = nextTurnRefused; + _ = await Record.ExceptionAsync(() => session.RunTurnAsync(Next, Ct)); + await session.FlushTranscriptAsync(); + await session.DisposeAsync(); + store.Refuses = false; + + ConversationSession reloaded = HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + store: store, + tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id), + conversationId: "conversation-1"); + _ = await reloaded.RunTurnAsync("thanks", Ct); + await reloaded.FlushTranscriptAsync(); + + List stored = Shapes((await store.ReadForSessionAsync("conversation-1", Ct)).Select(row => row.Content)); + Assert.Equal(nextTurnRefused ? [Question, "call", "result", "thanks", Answer] : [Question, "call", "result", Next, Answer, "thanks", Answer], stored); + Assert.Equal(1, tool.Runs); + } + // Order (b): the tool finishes while the next turn runs. That turn's request was built without it, so the + // call and result are stored behind that turn's rows, and the turn after reads them. + [Fact(Timeout = 20_000)] + public async Task AToolThatFinishesWhileTheNextTurnRunsRidesTheTurnAfter() + { + GatedTool tool = new(); + NewestWordsToolChatClient model = new("F63", Answer) { Held = Next }; + ConversationSession session = Create(model, tool); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await reading.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + Task next = session.RunTurnAsync(Next, Ct); + await model.Holding.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + tool.Release.SetResult(); + await session.ToolRuns.WhenIdle(); + model.Release.SetResult(); + _ = await next; + List nextRequest = model.Requests[^1]; + _ = await session.RunTurnAsync("thanks", Ct); + + Assert.DoesNotContain(nextRequest, message => message.Contents.OfType().Any()); + Assert.Equal([Question, Next, Answer, "call", "result", "thanks", Answer], Shapes(session.Transcript)); + Assert.Equal(1, tool.Runs); + } + + // Order (c): the conversation ends while the tool runs. The end backstop stops it 30 s later; nothing is carried. + [Fact(Timeout = 20_000)] + public async Task AToolACutTurnLeftRunningIsStoppedThirtySecondsAfterTheEnd() + { + FakeTimeProvider time = new(Start); + RecordingHook hook = new(); + GatedTool tool = new(); + ConversationSession session = Create(new NewestWordsToolChatClient("F63", Answer), tool, hook, time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await reading.WaitAsync(TimeSpan.FromSeconds(5), Ct); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await time.WaitForTimersAsync(Start + ConversationEnding.ToolGrace, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + time.Advance(ConversationEnding.ToolGrace - TimeSpan.FromSeconds(1)); + Assert.False(tool.Cancelled.Task.IsCompleted); + time.Advance(TimeSpan.FromSeconds(1)); + await tool.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + await session.ToolRuns.WhenIdle(); + await session.DisposeAsync(); + await session.FlushNoticesAsync(); + + Assert.DoesNotContain(session.Transcript, message => message.Contents.OfType().Any()); + Assert.Equal(ToolOutcome.Failed, Assert.Single(hook.Of()).Outcome); + } + + // A terminal stage ends the conversation as a host end does, so the same backstop stops the tool. + [Fact(Timeout = 20_000)] + public async Task AToolACutTurnLeftRunningIsStoppedThirtySecondsAfterATerminalStage() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + ConversationSession session = HookSessions.Create( + EndsAfterTheSecondTurnYaml, + new NewestWordsToolChatClient("F63", Answer), + tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id), + time: time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await reading.WaitAsync(TimeSpan.FromSeconds(5), Ct); + Assert.True((await session.RunTurnAsync(Next, Ct)).IsTerminal); + + await time.WaitForTimersAsync(time.GetUtcNow() + ConversationEnding.ToolGrace, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + time.Advance(ConversationEnding.ToolGrace); + await tool.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + await session.ToolRuns.WhenIdle(); + } + + /// A store that refuses every append of words while is set, as when another host saved the turn first. + private sealed class RefusingStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + public bool Refuses { get; set; } + + public override ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + return Refuses + ? throw new ConversationTurnConflictException("another host saved this turn first.") + : base.AppendAsync(conversationId, messages, state, cancellationToken); + } + } + + private static ConversationSession Create(IChatClient model, GatedTool tool, AgentHook? hook = null, TimeProvider? time = null) + { + return HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + hook is null ? [] : [hook], + tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id), + time: time); + } + + private static List Shapes(IEnumerable transcript) => + [ + .. transcript.Select(message => message.Contents switch + { + var contents when contents.OfType().Any() => "call", + var contents when contents.OfType().Any() => "result", + _ => message.Text, + }), + ]; + + private static Task ReadAllAsync(TurnRun run) + { + return Task.Run( + async () => + { + await using (run) + { + await foreach (ChatResponseUpdate _ in run.Updates.WithCancellation(Ct)) + { + } + } + }, + Ct); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/DelegatedToolsTests.cs b/tests/AgentCore.Application.Tests/Runtime/DelegatedToolsTests.cs index a3c390b4..c9125d0e 100644 --- a/tests/AgentCore.Application.Tests/Runtime/DelegatedToolsTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/DelegatedToolsTests.cs @@ -2,28 +2,16 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { /// /// Tools that belong to one conversation, offered to the runs that call delegates and to nothing else. /// - /// - /// - /// The compiled agent is a process singleton, so a tool that belongs to one conversation cannot be compiled - /// onto it. It travels on the turn instead, and the gate is the id of the delegating tool the run - /// sits under. A gate on the agent NAME would be wrong: CompiledAgentRegistry makes compiled - /// agents singletons, so two kind: agent declarations can name one agent and both would be - /// handed the tool. - /// - /// - /// Every test here runs offline: no network conversation and no API key. - /// - /// public sealed class DelegatedToolsTests { private const string DelegationYaml = diff --git a/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs b/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs index a60d4265..84143060 100644 --- a/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs +++ b/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs @@ -2,9 +2,9 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.TestSupport; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs new file mode 100644 index 00000000..05f09e88 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Agents.Graph; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The non-streaming half of a graph participant's run. The MAF 1.21 workflow always streams its + /// participants, so only a graph of our own reaches this path. + /// + public sealed class GraphParticipantAgentTests + { + [Fact] + public async Task ARunWithoutStreamingCarriesTheGraphsTurnNestedOnTheOptionsAndTheSession() + { + TurnInvocation turn = new() { ConversationId = "conversation-7", TurnIndex = 3, Stage = "" }; + TurnProbeAgent inner = new(); + GraphParticipantAgent participant = new(inner); + OuterRunAgent graph = new(async () => + { + AgentSession session = await participant.CreateSessionAsync(TestContext.Current.CancellationToken); + _ = await participant.RunAsync([new ChatMessage(ChatRole.User, "hi")], session, cancellationToken: TestContext.Current.CancellationToken); + }); + TurnProbeAgent.Session graphSession = new(); + TurnRegistry.Set(graphSession, turn); + + _ = await graph.RunAsync([new ChatMessage(ChatRole.User, "hi")], graphSession, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(("conversation-7", 3, true), Facts(inner.FromOptions)); + Assert.Equal(("conversation-7", 3, true), Facts(inner.FromSession)); + } + + // A participant that published a file and then failed still hands the caller the file. + [Fact] + public async Task AFilePublishedBeforeTheRunThrowsStillReachesTheCaller() + { + TurnNotices notices = new(); + TurnInvocation turn = new() { ConversationId = "conversation-7", TurnIndex = 3, Stage = "", Notices = notices }; + GraphParticipantAgent participant = new(new PublishThenThrowAgent()); + OuterRunAgent graph = new(async () => + { + AgentSession session = await participant.CreateSessionAsync(TestContext.Current.CancellationToken); + _ = await participant.RunAsync([new ChatMessage(ChatRole.User, "hi")], session, cancellationToken: TestContext.Current.CancellationToken); + }); + TurnProbeAgent.Session graphSession = new(); + TurnRegistry.Set(graphSession, turn); + + _ = await Assert.ThrowsAsync( + () => graph.RunAsync([new ChatMessage(ChatRole.User, "hi")], graphSession, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.True(notices.Reader.TryRead(out NoticeContent? posted)); + FileNotice card = Assert.IsType(posted); + Assert.Equal(("rows.csv", "publisher"), (card.File.Name, card.Author)); + } + + private static (string?, int?, bool?) Facts(TurnInvocation? turn) => (turn?.ConversationId, turn?.TurnIndex, turn?.Nested); + + /// Publishes a file the way file.publish files it, under its tool call, then fails the run. + private sealed class PublishThenThrowAgent : AIAgent + { + public override string? Name => "publisher"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + return new(new TurnProbeAgent.Session()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + TurnRegistry.For(session)?.Files?.Publish(new FileContent { Name = "rows.csv", FileId = "rows.csv" }, "call-1"); + throw new InvalidOperationException("the participant failed after it published"); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/BackgroundChildAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/BackgroundChildAgentTests.cs index 2cc38f58..20d1cc46 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/BackgroundChildAgentTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/BackgroundChildAgentTests.cs @@ -1,6 +1,5 @@ #pragma warning disable MAAI001 // The context types are evaluation-only in Microsoft.Agents.AI 1.21.0. -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; @@ -13,11 +12,6 @@ namespace AgentCore.Application.Tests.Runtime.Harness /// A child session created inside the parent's run carries the parent's conversation id; one created /// outside any run carries nothing. /// - /// - /// BackgroundAgentsProvider creates the child session from inside the parent's tool call. - /// The test stands in for that with a provider that creates it from inside the parent's - /// InvokingAsync, which is the same run context. - /// public sealed class BackgroundChildAgentTests { [Fact] diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs index 3391a43f..0883ce4b 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs @@ -1,6 +1,5 @@ using System.Text.Json; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Runtime.Turn; using Microsoft.Agents.AI; @@ -14,7 +13,7 @@ namespace AgentCore.Application.Tests.Runtime.Harness /// /// as a unit: it serves the framework's file tools rooted at the /// workspace of the turn filed for the invoking session, and serves an empty context when no turn - /// is filed (a delegated agent's run, design §6.3#6, inverse of probe P11c). + /// is filed (a delegated agent's run). /// public sealed class ConversationFilesProviderTests : IDisposable { @@ -46,7 +45,7 @@ public async Task InvokingAsync_WithASessionFiledTurn_ServesFileTools() Assert.NotEmpty(context.Tools); } - // Inverse of P11c (design §6.3#6): a run with no filed turn gets an empty context, not a throw. + // A run with no filed turn gets an empty context, not a throw. [Fact] public async Task InvokingAsync_WithoutATurn_ServesAnEmptyContext() { diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs index 9a46db16..734fa793 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs @@ -4,13 +4,13 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime.Harness { @@ -77,7 +77,7 @@ public async Task CloseAsync_WithAChildStillRunning_CancelsIt() _ = await session.RunTurnAsync("go", token); // The turn is over and the conversation is not, and the child is still in its model call: the - // leak this step closes. Nothing has cancelled it yet. + // leak the release closes. Nothing has cancelled it yet. await child.Started.WaitAsync(TimeSpan.FromSeconds(10), token); Assert.False(child.Cancelled.IsCompleted); diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs index 845915dc..84c0062f 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs @@ -6,13 +6,13 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime.Harness { @@ -114,7 +114,7 @@ public async Task ASnapshotDuringACatchUp_NeverPairsTheOtherHostsProvidersWithTh await using ConversationSession b = Build(other, new DeafChatClient(), store, yaml: LingeringTodosYaml).Create("conversation-1"); await StartAChildThenLetTheOtherHostTakeATurnAsync(a, b, child); - string own = Providers(a.Snapshot()); + string own = Providers(a.States.Snapshot()); ConversationRecord? record = await store.GetAsync("conversation-1", Token); Assert.Equal(2, record!.State!.NextTurnIndex); Assert.NotEqual(own, Providers(record.State)); @@ -127,11 +127,11 @@ public async Task ASnapshotDuringACatchUp_NeverPairsTheOtherHostsProvidersWithTh List seen = []; while (!parent.Entered.Task.IsCompleted && !caughtUp.IsCompleted) { - seen.Add(a.Snapshot()); + seen.Add(a.States.Snapshot()); await Task.Delay(TimeSpan.FromMilliseconds(1), Token); } - seen.Add(a.Snapshot()); + seen.Add(a.States.Snapshot()); Assert.All(seen, snapshot => Assert.Equal(snapshot.NextTurnIndex == 1, Providers(snapshot) == own)); Assert.Equal(2, seen[^1].NextTurnIndex); diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs index 6b5164bc..244e2d47 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs @@ -3,12 +3,12 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime.Harness { diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs index 5b08f5f3..52527a95 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; @@ -12,15 +11,18 @@ using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; namespace AgentCore.Application.Tests.Runtime.Harness { /// - /// A store 1 append that fails, or that the store saved and then reported failed, while no other session writes the + /// A message store append that fails, or that the store saved and then reported failed, while no other session writes the /// conversation: the next turn takes the store's words back and keeps the session's own harness state, which its - /// commit then writes again. Only a store that another session wrote in the meantime replaces that state (the P1 - /// case in ). + /// commit then writes again. Only a store that another session wrote in the meantime replaces that state (see + /// ). /// public sealed class ConversationSessionDroppedWriteTests { diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs index 67d3fc37..51e42282 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs @@ -5,7 +5,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.TestSupport; using Microsoft.Agents.AI; @@ -13,13 +12,14 @@ using Xunit; using AgentCore.Domain; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime.Harness { /// - /// Harness step: a graph row whose document declares a harness switch keeps one workflow + /// A graph row whose document declares a harness switch keeps one workflow /// session for the whole conversation, so todos: (and mode:) survive turns and resume. - /// A graph row without one still runs every turn fresh, exactly as before. + /// A graph row without one still runs every turn fresh. /// public sealed class ConversationSessionGraphStateTests { diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs index 69142a76..350a0163 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs @@ -12,11 +12,12 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime.Harness { /// - /// The end-to-end path of harness step 4: a todos: item written during a turn lands in store + /// The end-to-end path of a todos: item written during a turn lands in store /// 0's Providers, and a conversation that resumes — from the same store, or from a host checkpoint — /// gets it re-injected by the framework's own . /// @@ -150,7 +151,7 @@ public async Task AResumeFromAHostCheckpoint_ThroughAgentCoreAgent_ReInjectsTheT firstSession, cancellationToken: TestContext.Current.CancellationToken); // A fresh store: this proves the checkpoint carries the provider state on its own, not - // because store 0 still remembers the conversation. + // because the conversation store still remembers the conversation. InMemoryConversationStore secondStore = new(); SequencedChatClient secondChatClient = new("hello there."); CompiledAgent secondCompiled = Compile(TodosYaml, secondChatClient, secondStore); diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs index 43dcd491..2314a71f 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; @@ -10,6 +9,7 @@ using Microsoft.Agents.AI.Tools.Shell; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Runtime.Harness @@ -74,7 +74,7 @@ public void Dispose() [Fact] public async Task FirstRequest_ListsATool_NamedRunShell() { - // One literal assertion, as the brief allows: it pins the name the model actually sees. + // One literal assertion: it pins the name the model actually sees. Assert.Equal("run_shell", RunShellToolName); ShellScriptedClient client = new(RunShellToolName, "pwd", "rm -rf x", "echo $$"); @@ -183,9 +183,9 @@ public async Task CloseAsync_WhenTheStoreThrowsOnFlush_StillDisposesTheShell() // AgentCoreChatHistoryProvider.WriteAfterAsync catches every exception a store write // raises and reports it through ITranscriptLossCounter.Dropped, whose own contract is that it // never throws back out — "the conversation outlives a store that refuses" — so FlushTranscriptAsync - // itself surfaces nothing here, which is today's behaviour: CloseAsync completes rather than - // throw. What this proves is the part that used to be at risk: even though the flush ate the - // fault silently, the shell this conversation started is still disposed once CloseAsync returns. + // itself surfaces nothing here and CloseAsync completes rather than throw. What this proves: even + // though the flush ate the fault silently, the shell this conversation started is still disposed + // once CloseAsync returns. ThrowingConversationStore store = new(); ShellScriptedClient client = new(RunShellToolName, "echo $$"); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ShellYaml); diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellsTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellsTests.cs index c588d007..8eef8906 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellsTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellsTests.cs @@ -38,18 +38,19 @@ public async Task Get_Local_RunsInTheConversationsWorkspace() Assert.Contains(expected, result.Stdout, StringComparison.Ordinal); } + // The command runs 30 times the timeout, so a run that waited for it cannot pass even on a loaded machine. [Fact] - public async Task Get_Local_TimeoutSignalsWithoutThrowing_AndReturnsUnder3Seconds() + public async Task Get_Local_TimeoutSignalsWithoutThrowing_AndReturnsBeforeTheCommandEnds() { await using ConversationShells shells = new(_workspace, logger: null); ConversationShellOptions options = new(ShellKind.Local, Policy: null, Timeout: TimeSpan.FromSeconds(1)); Stopwatch stopwatch = Stopwatch.StartNew(); - ShellResult result = await shells.Get(options).RunAsync("sleep 3", TestContext.Current.CancellationToken); + ShellResult result = await shells.Get(options).RunAsync("sleep 30", TestContext.Current.CancellationToken); stopwatch.Stop(); - Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), - $"expected the timed-out run to return in under 3s; took {stopwatch.Elapsed}"); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(20), + $"expected the timed-out run to return well before the 30 s command ended; took {stopwatch.Elapsed}"); Assert.True(result is { TimedOut: true, ExitCode: 124 }, $"expected TimedOut=true and ExitCode=124; got TimedOut={result.TimedOut}, ExitCode={result.ExitCode}"); } @@ -75,7 +76,7 @@ public async Task Get_NullAllowListPlusOneDeny_AllowsWhatItDoesNotDeny() { // This proves ShellPolicy's own semantics directly, at the ConversationShells layer: a null // allowList disables the allow check entirely, so only the deny list refuses a command. - // ShellPolicy also treats a supplied-but-empty allow list as deny-all (per its own remarks), + // ShellPolicy also treats a supplied-but-empty allow list as deny-all, // which is why AgentHarnessProviders.BuildPolicy must translate an agent's empty allow: into // null rather than pass it through — that compile-time translation, and the end-to-end // consequence of getting it wrong (an undenied command like pwd being refused), is proven in diff --git a/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs b/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs index 6c8a8801..5bc313ac 100644 --- a/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs +++ b/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs @@ -1,9 +1,10 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs index 4e158082..b3917535 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs @@ -2,12 +2,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.InterruptionSessions; namespace AgentCore.Application.Tests.Runtime @@ -75,7 +76,7 @@ public static InterruptionFixture StartWithProseBesideTool(string reply) return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); } - /// Builds a session whose extractor model never answers, so the deadline of §8.7 must act. + /// Builds a session whose extractor model never answers, so the extraction deadline must act. public static InterruptionFixture StartWithHangingExtractor(string reply) { ScriptedChatClient replyClient = new(reply.Split(' ')); @@ -109,7 +110,7 @@ public async Task InterruptAfterFirstUpdateAsync(string heard) { await _firstUpdate.Task.WaitAsync(_cancellation.Token).ConfigureAwait(false); - // Section 7.1: the relay reports both the heard text and the played duration together. + // The relay reports both the heard text and the played duration together. _ = Session.Cut(0, new TurnCut(heard, TimeSpan.FromMilliseconds(1820))); _openGate(); diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs index d516b7f9..e574d256 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs @@ -3,12 +3,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { @@ -80,10 +81,10 @@ internal static ConversationSession CreateSession( Func? tools = null, IAuditSinkPort? auditSink = null) { - // ConversationObservers.Standard takes a required sink, because the composition root resolves - // providers.audit for every host and falls back to the in-process memory kind. An optional - // parameter has to be a compile-time constant, so the default is spelled here instead — a fact - // that does not care where its events land gets a fresh in-memory sink. + // The audit hook takes a required sink, because the composition root resolves providers.audit for + // every host and falls back to the in-process memory kind. An optional parameter has to be a + // compile-time constant, so the default is spelled here instead — a fact that does not care where + // its events land gets a fresh in-memory sink. IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); @@ -99,7 +100,7 @@ internal static ConversationSession CreateSession( compiled, new GuardEvaluator(compiled.Configuration.Guards), ConversationSessionFactory.CreateExtractor(compiled, chatClients), - observers: ConversationObservers.Standard(sink, logger: null)); + hooks: BuiltInHooks.Create(sink)); return factory.Create(); } diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs index 55a43c72..3a09895d 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs @@ -4,14 +4,20 @@ namespace AgentCore.Application.Tests.Runtime { /// - /// Answers the first call to the one declared tool at once, and blocks the second call until the - /// run's own cancellation ends it. + /// Answers the first call to the one declared tool at once, and blocks the second call until + /// completes. A cancellation that reaches the second call ends it unanswered. /// internal sealed class PartiallyAnsweredToolFactory { + /// The answer the second call gives once released. + public const string SecondAnswer = "70"; + /// Signals once the second call is in flight and blocked. public TaskCompletionSource SecondConversationStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Lets the blocked second call answer. + public TaskCompletionSource ReleaseSecond { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public AIFunction? Create(ToolConfiguration tool) { ArgumentNullException.ThrowIfNull(tool); @@ -26,10 +32,8 @@ private async Task AnswerAsync(int index, CancellationToken cancellation } _ = SecondConversationStarted.TrySetResult(); - await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); - - // Unreachable: the delay above only ever ends in cancellation. - return string.Empty; + await ReleaseSecond.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + return SecondAnswer; } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/KnowledgeScopeThroughConversationSessionTests.cs b/tests/AgentCore.Application.Tests/Runtime/KnowledgeScopeThroughConversationSessionTests.cs index 7657b7f4..bb5d74b6 100644 --- a/tests/AgentCore.Application.Tests/Runtime/KnowledgeScopeThroughConversationSessionTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/KnowledgeScopeThroughConversationSessionTests.cs @@ -3,12 +3,12 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.Domain.Knowledge; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime { @@ -16,19 +16,6 @@ namespace AgentCore.Application.Tests.Runtime /// The one wiring a host can actually write: set a knowledge scope on the session, run a turn, /// and have the scope still be there when the retrieval delegate reads it. /// - /// - /// - /// Every other test of the scope sets it and reads it back without a turn loop in between, and - /// every one of them passed while the feature was wired shut: the loop built its turn without the - /// session's scope, so the store never saw it. The defect lives in the interaction, so the test - /// has to run the interaction. - /// - /// - /// The agent here is scoped: true, which makes the erasure visible twice over: the provider's - /// own gate refuses to call the store at all with no scope set, so a broken carry-through shows up - /// as a store nobody searched as well as a scope nobody could read. - /// - /// public sealed class KnowledgeScopeThroughConversationSessionTests { private const string Yaml = @@ -47,6 +34,20 @@ public sealed class KnowledgeScopeThroughConversationSessionTests - { id: greeting, agent: only } """; + private const string GraphYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: researcher, instructions: "look it up", knowledge: { mode: prefetch, scoped: true } } + - { id: responder, instructions: "answer the caller", knowledge: { mode: prefetch, scoped: true } } + entries: + main: + graph: + pattern: sequential + agents: [ researcher, responder ] + """; + /// /// The document the three Scope_ facts run against. Unlike , it declares /// providers.knowledge.scope, so StateKnowledgeScope.Compose builds a scope from @@ -128,6 +129,22 @@ public async Task RunTurnStreamingAsync_UnderAHostScope_TheStoreStillSeesThatSco Assert.Same(scope, port.ScopeAtTheStore); } + [Fact] + public async Task RunTurnAsync_OnAGraphRow_EveryParticipantsStoreSeesTheHostScope() + { + // A graph participant runs on a session of its own; the turn's scope must still reach it. + using SequencedChatClient reply = new("hello there."); + StubKnowledgePort port = new([Card("a")]); + ConversationSession session = Build(reply, port, GraphYaml).Create("conversation-graph"); + + KnowledgeScope scope = Scope(); + session.Scope = scope; + _ = await session.RunTurnAsync("the screen says e33", TestContext.Current.CancellationToken); + + Assert.Equal(2, port.Calls); + Assert.Same(scope, port.ScopeAtTheStore); + } + [Fact] public async Task RunTurnAsync_WithNoHostScope_LeavesTheAmbientAbsent() { @@ -245,7 +262,7 @@ private ConversationSession ResumedSession(IChatClient reply, IReadOnlyDictionar { _port = new StubKnowledgePort([Card("a")]); - // The real restore path: a ConversationSessionState of the shape store 0 hands back, fed through + // The real restore path: a ConversationSessionState of the shape the conversation store hands back, fed through // ConversationSessionFactory.Create so Resume/Restore write the slots, rather than the test poking // session.State directly and skipping the coercion and enum checks Restore applies. ConversationSessionState state = new() diff --git a/tests/AgentCore.Application.Tests/Runtime/ModelFacingChatClientTests.cs b/tests/AgentCore.Application.Tests/Runtime/ModelFacingChatClientTests.cs index 0ef539b6..5c37fddf 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ModelFacingChatClientTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ModelFacingChatClientTests.cs @@ -1,8 +1,8 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.Domain.Sources; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/OuterRunAgent.cs b/tests/AgentCore.Application.Tests/Runtime/OuterRunAgent.cs new file mode 100644 index 00000000..f27f8595 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/OuterRunAgent.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Runs a body inside a run of its own, the way a workflow runs its participants. + internal sealed class OuterRunAgent(Func body) : AIAgent + { + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override async Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + await body(); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok")); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs b/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs index 32fa378e..37801196 100644 --- a/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs +++ b/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs @@ -3,6 +3,7 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Agents.Graph; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs b/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs index 4c89df4a..8cc4cd6c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs +++ b/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs @@ -1,13 +1,14 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// - /// Stands in for the conversation's completer, as the design probes' ProtoCommitAgent did: its state is + /// Stands in for the conversation's completer: its state is /// stage-after: plus the reply, so a test can tell the state after the stage advance from any other. /// It logs staged when the framework's hook had staged the run by the time it ran, then complete. /// diff --git a/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs b/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs new file mode 100644 index 00000000..32385c0a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs @@ -0,0 +1,280 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Audit; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// A withdraw or a cut lets a running tool finish, keeps its + /// call and result in the history of the next turn, and so never makes the tool run twice. + /// + public sealed class RunningToolInterruptTests + { + private const string Question = "Max speed of the F63?"; + + private const string Combined = "Max speed of the F63? Oh wait, I meant the F80"; + + private const string Answer = "ok"; + + private const string Later = "and the F80?"; + + private static readonly DateTimeOffset Start = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private const string StoppedAtEnd = "stopped 30 s after the conversation ended."; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The GPT-Live correction: the older turn is cancelled, then resent on the same parent. + [Fact(Timeout = 10_000)] + public async Task ACorrectionWhileAToolRunsLetsItFinishOnceAndTheNewTurnReadsItsResult() + { + RecordingHook hook = new(); + GatedTool tool = new(); + CueToolChatClient model = new("F63", () => [], Answer); + ConversationSession session = Create(model, tool, hook); + _ = await session.RunTurnAtOriginAsync("hello", new ConversationTurnOrigin("d0", null) { NamesParent = true }, Ct); + string anchor = session.LastReplyMessageId!; + + using CancellationTokenSource older = CancellationTokenSource.CreateLinkedTokenSource(Ct); + TurnRun first = await session.StartTurnAsync( + new ChatMessage(ChatRole.User, Question), new ConversationTurnOrigin("d1", anchor) { NamesParent = true }, older.Token); + Task reading = ReadAllAsync(first); + await tool.Entered.Task; + + await older.CancelAsync(); + await EndedAsync(reading); + tool.Release.SetResult(); + await session.ToolRuns.WhenIdle(); + + TurnResult newer = await session.RunTurnAtOriginAsync(Combined, new ConversationTurnOrigin("d2", anchor) { NamesParent = true }, Ct); + await session.FlushNoticesAsync(); + + Assert.Equal((1, 1), (tool.Runs, tool.Finished)); + Assert.Equal(Answer, newer.ReplyText); + Assert.Contains(model.Requests[^1], message => HasResult(message)); + Assert.Equal(["hello", Answer, Combined, "call", "result", Answer], Shapes(session.Transcript)); + _ = Assert.Single(hook.Of()); + Assert.Equal(ToolOutcome.Ok, Assert.Single(hook.Of()).Outcome); + } + + // The Telnyx barge-in: a cut while the tool runs. The end-of-conversation backstop does not apply to a cut, and the + // cut turn ends at once: the call and result ride the next turn. + [Fact(Timeout = 10_000)] + public async Task ACutWhileAToolRunsLetsItFinishAndKeepsItsCallAndResult() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + CueToolChatClient model = new("F63", () => [], Answer); + RecordingHook hook = new(); + ConversationSession session = Create(model, tool, hook, time: time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await EndedAsync(reading); + time.Advance(TimeSpan.FromMinutes(2)); + Assert.False(tool.Cancelled.Task.IsCompleted); + tool.Release.SetResult(); + await session.ToolRuns.WhenIdle(); + _ = await session.RunTurnAsync(Later, Ct); + + Assert.Equal((1, 1), (tool.Runs, tool.Finished)); + Assert.Contains(model.Requests[^1], message => HasResult(message)); + Assert.Equal([Question, "call", "result", Later, Answer], Shapes(session.Transcript)); + await session.FlushNoticesAsync(); + Assert.Equal(ToolOutcome.Ok, Assert.Single(hook.Of()).Outcome); + } + + // Only a call already running finishes: a later call of the same round never starts once the turn is cut. + [Fact(Timeout = 10_000)] + public async Task ACutLeavesTheRoundsLaterCallUnrun() + { + GatedTool tool = new(); + using ParallelToolChatClient model = new(Answer, ("price_lookup", "call_a"), ("price_lookup", "call_b")); + ConversationSession session = Create(model, tool); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + + Assert.True(session.Cut(run.TurnIndex, new TurnCut(string.Empty, TimeSpan.Zero))); + await EndedAsync(reading); + tool.Release.SetResult(); + await session.ToolRuns.WhenIdle(); + _ = await session.RunTurnAsync(Combined, Ct); + + Assert.Equal((1, 1), (tool.Runs, tool.Finished)); + FunctionResultContent ran = Assert.Single(session.Transcript.SelectMany(message => message.Contents).OfType(), result => result.CallId == "call_a"); + Assert.Equal(GatedTool.Result, ran.Result?.ToString()); + Assert.DoesNotContain( + session.Transcript.SelectMany(message => message.Contents).OfType(), + result => result.CallId == "call_b" && result.Result?.ToString() == GatedTool.Result); + } + + // A tool still running when the conversation ends gets 30 s more, then is cancelled. + [Fact(Timeout = 10_000)] + public async Task AToolStillRunningWhenTheConversationEndsIsCancelledThirtySecondsLater() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + CueToolChatClient model = new("F63", () => [], Answer); + RecordingHook hook = new(); + ConversationSession session = Create(model, tool, hook, time: time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await time.WaitForTimersAsync(Start + TimeSpan.FromSeconds(30), 1); + time.Advance(TimeSpan.FromSeconds(29)); + Assert.False(tool.Cancelled.Task.IsCompleted); + time.Advance(TimeSpan.FromSeconds(1)); + await tool.Cancelled.Task; + await EndedAsync(reading); + + await session.FlushNoticesAsync(); + + Assert.Equal((1, 0), (tool.Runs, tool.Finished)); + Assert.DoesNotContain(session.Transcript, HasResult); + ToolCalled stopped = Assert.Single(hook.Of()); + Assert.Equal((ToolOutcome.Failed, StoppedAtEnd), (stopped.Outcome, stopped.Failure)); + } + + // The stopped call is a tool.failed row of the audit chain, so the audit shows the call ran and was stopped. + [Fact(Timeout = 10_000)] + public async Task AToolStoppedThirtySecondsAfterTheEndIsAToolFailedRow() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + InMemoryAuditSink sink = new(); + CueToolChatClient model = new("F63", () => [], Answer); + ConversationSession session = ConversationSessionAuditTestSupport.Build( + ConversationSessionAuditTestSupport.ToolYaml, + model, + fill: null, + declared => tool.Create(declared.Id, declared.Description ?? declared.Id), + time, + sink).Create("conversation-1"); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await time.WaitForTimersAsync(Start + TimeSpan.FromSeconds(30), 1); + time.Advance(TimeSpan.FromSeconds(30)); + await tool.Cancelled.Task; + await EndedAsync(reading); + + AuditEvent failed = Assert.Single(await session.RowsAsync(sink), row => row.Kind == AuditEventKind.ToolFailed); + Assert.Equal(StoppedAtEnd, failed.Payload[AuditPayloadKeys.ToolError]); + } + + [Fact(Timeout = 10_000)] + public async Task AToolThatFinishesWithinThirtySecondsOfTheEndKeepsItsResult() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + CueToolChatClient model = new("F63", () => [], Answer); + ConversationSession session = Create(model, tool, time: time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await time.WaitForTimersAsync(Start + TimeSpan.FromSeconds(30), 1); + time.Advance(TimeSpan.FromSeconds(29)); + tool.Release.SetResult(); + await EndedAsync(reading); + + Assert.Equal((1, 1), (tool.Runs, tool.Finished)); + Assert.Contains(session.Transcript, HasResult); + } + + // Host shutdown disposes the session, which waits for the running turn: the backstop bounds that wait too. + [Fact(Timeout = 10_000)] + public async Task AToolStillRunningWhenTheSessionIsDisposedIsCancelledThirtySecondsLater() + { + FakeTimeProvider time = new(Start); + GatedTool tool = new(); + CueToolChatClient model = new("F63", () => [], Answer); + ConversationSession session = Create(model, tool, time: time); + + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, Question), origin: null, Ct); + Task reading = ReadAllAsync(run); + await tool.Entered.Task; + + Task disposing = session.DisposeAsync().AsTask(); + await time.WaitForTimersAsync(Start + TimeSpan.FromSeconds(30), 1); + time.Advance(TimeSpan.FromSeconds(29)); + Assert.False(tool.Cancelled.Task.IsCompleted); + time.Advance(TimeSpan.FromSeconds(1)); + await tool.Cancelled.Task; + await EndedAsync(reading); + await disposing; + + Assert.Equal((1, 0), (tool.Runs, tool.Finished)); + } + + private static ConversationSession Create(IChatClient model, GatedTool tool, AgentHook? hook = null, TimeProvider? time = null) + { + return HookSessions.Create( + HookSessions.ToolAgentYaml, + model, + hook is null ? [] : [hook], + tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id), + time: time); + } + + private static bool HasResult(ChatMessage message) => + message.Contents.OfType().Any(result => result.Result?.ToString() == GatedTool.Result); + + private static List Shapes(IEnumerable transcript) => + [ + .. transcript.Select(message => message.Contents switch + { + var contents when contents.OfType().Any() => "call", + var contents when contents.OfType().Any() => "result", + _ => message.Text, + }), + ]; + + // A run the host cancelled ends in its cancellation; one whose model never read the token ends on its own. + private static async Task EndedAsync(Task reading) + { + try + { + await reading; + } + catch (OperationCanceledException) + { + } + } + + private static async Task ReadAllAsync(TurnRun run) + { + await using (run) + { + await foreach (ChatResponseUpdate _ in run.Updates) + { + } + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/RunningTurnSupersedeTests.cs b/tests/AgentCore.Application.Tests/Runtime/RunningTurnSupersedeTests.cs new file mode 100644 index 00000000..712089a0 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/RunningTurnSupersedeTests.cs @@ -0,0 +1,116 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Edit-and-resend withdraws a turn that is still running, so a caller's + /// correction replaces the question it corrects and the model reads the caller's whole thought once. + /// + public sealed class RunningTurnSupersedeTests + { + private const string Question = "Max speed of the F63?"; + + private const string Combined = "Max speed of the F63? Oh wait, I meant the F80"; + + private const string Reply = "The F80 tops out at 10.5 mph."; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The path PhoneCall takes: it cancels the older turn itself, then resends on the same parent. + [Fact(Timeout = 10_000)] + public async Task ACancelledRunningTurnResentOnItsParentIsWithdrawnAndTheModelReadsTheWordsOnce() + { + RecordingHook hook = new(); + using ScriptedChatClient scripted = new(Reply); + GatedChatClient gated = new(scripted); + RequestCapturingChatClient model = new(gated); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [hook]); + _ = await session.RunTurnAtOriginAsync("hello", new ConversationTurnOrigin("d0", null) { NamesParent = true }, Ct); + string anchor = session.LastReplyMessageId!; + + gated.Arm(); + using CancellationTokenSource older = CancellationTokenSource.CreateLinkedTokenSource(Ct); + TurnRun first = await session.StartTurnAsync( + new ChatMessage(ChatRole.User, Question), new ConversationTurnOrigin("d1", anchor) { NamesParent = true }, older.Token); + Task reading = ReadAllAsync(first); + await gated.Entered.Task; + + await older.CancelAsync(); + _ = await Assert.ThrowsAnyAsync(() => reading); + gated.Open.SetResult(); + + _ = await session.RunTurnAtOriginAsync(Combined, new ConversationTurnOrigin("d2", anchor) { NamesParent = true }, Ct); + await session.FlushNoticesAsync(); + + AssertTheCorrectionReplacedTheQuestion(model, session, hook, first.TurnIndex, supersededExpected: true); + } + + // The engine on its own: a second start on the same parent waits the running turn out, then + // withdraws it. + [Fact(Timeout = 30_000)] + public async Task ASecondStartOnTheSameParentWaitsTheRunningTurnOutThenWithdrawsIt() + { + RecordingHook hook = new(); + using ScriptedChatClient scripted = new(Reply); + GatedChatClient gated = new(scripted); + RequestCapturingChatClient model = new(gated); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [hook]); + _ = await session.RunTurnAtOriginAsync("hello", new ConversationTurnOrigin("d0", null) { NamesParent = true }, Ct); + string anchor = session.LastReplyMessageId!; + + gated.Arm(); + TurnRun first = await session.StartTurnAsync( + new ChatMessage(ChatRole.User, Question), new ConversationTurnOrigin("d1", anchor) { NamesParent = true }, Ct); + Task reading = ReadAllAsync(first); + await gated.Entered.Task; + + Task second = session.RunTurnAtOriginAsync(Combined, new ConversationTurnOrigin("d2", anchor) { NamesParent = true }, Ct); + gated.Open.SetResult(); + await reading; + _ = await second; + await session.FlushNoticesAsync(); + + AssertTheCorrectionReplacedTheQuestion(model, session, hook, first.TurnIndex, supersededExpected: true); + } + + [AssertionMethod] + private static void AssertTheCorrectionReplacedTheQuestion( + RequestCapturingChatClient model, ConversationSession session, RecordingHook hook, int olderTurn, bool supersededExpected) + { + IReadOnlyList lastRequest = model.Requests[^1]; + Assert.Equal(1, lastRequest.Count(message => message.Role == ChatRole.User && message.Text == Combined)); + Assert.DoesNotContain(lastRequest, message => message.Text == Question); + + Assert.Equal(["hello", Reply, Combined, Reply], session.Transcript.Select(message => message.Text)); + + if (supersededExpected) + { + TurnSuperseded superseded = Assert.Single(hook.Of()); + Assert.Equal((olderTurn, olderTurn), (superseded.WithdrewFrom, superseded.WithdrewThrough)); + } + else + { + Assert.Empty(hook.Of()); + } + } + + private static async Task ReadAllAsync(TurnRun run) + { + await using (run) + { + await foreach (ChatResponseUpdate _ in run.Updates) + { + } + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs b/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs index d15516ff..ccce9e15 100644 --- a/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs +++ b/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs @@ -3,6 +3,7 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Tools; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Runtime { @@ -29,11 +30,6 @@ public void Advance(TimeSpan span) /// /// A deterministic offline model that answers one reply for each request, in order. /// - /// - /// The turn loop makes two model calls in one turn, the reply and the extractor, and they need - /// different answers. This client answers a list, and it holds the last answer once the list runs - /// out. - /// internal sealed class SequencedChatClient(params string[] replies) : IChatClient { private readonly string[] _replies = replies; @@ -123,11 +119,6 @@ public void Dispose() /// /// A model that yields lifecycle updates between its text fragments. /// - /// - /// Section 8.6 measured AsAIAgent(): 47 updates for 40 text fragments, and seven of them carry - /// no content. This client reproduces both empty shapes, an update with no content at all and an - /// update whose only content is empty text, so a test proves the seam drops them. - /// internal sealed class LifecycleChatClient(params string[] fragments) : IChatClient { private readonly string[] _fragments = fragments; @@ -199,11 +190,6 @@ public void Dispose() /// /// A model that calls the first tool it is offered on every request, and never answers with text. /// - /// - /// conversations one tool once, which keeps a healthy run finite. Section - /// 8.7 needs the other case: a tool that keeps failing. This client never stops calling, so the run - /// spends the error budget of MaximumConsecutiveErrorsPerRequest and the 4th failure throws. - /// internal sealed class LoopingToolCallingChatClient : IChatClient { private int _calls; @@ -273,25 +259,6 @@ public void Dispose() /// Builds one function for each declared tool, and every one of them throws a fault the model /// cannot answer. /// - /// - /// - /// A builtin tool returns an error result rather than throwing, so this factory stands for - /// the case section 8.7 keeps for a defect: the fault is beyond the model, and the 4th consecutive - /// one throws out of the run and spends the framework's own budget. - /// - /// - /// Task 7a moved that classification off and - /// into , the framework's single choke point for - /// every tool call. This factory builds a bare AIFunctionFactory tool, which is not a - /// at all, so it is the one thing in this file - /// that still exercises the classification of a fault reaching that middleware with no tool kind in - /// front of it. The exception type is deliberately one IsBeyondTheModel classifies as beyond - /// the model — before the move that was true of every exception this factory could throw, because - /// nothing classified it either way; after the move, only this arm keeps the run finite, since an - /// answerable fault would now become an error result the model reads and - /// would call the tool again forever. - /// - /// internal sealed class ThrowingToolBuilder { /// The message every fault carries. @@ -319,11 +286,6 @@ private string Fail() /// /// Builds one function for each declared tool, and answers it with a fixed JSON document. /// - /// - /// The turn loop reads a tool result out of the finished turn and hands it to - /// ToolStateWriter. This factory gives it something to read without an HTTP adapter. - /// - /// Creates the factory. /// The JSON document each tool answers. /// /// Whether the tool answers the document as one string. A real tool answers either way, because a @@ -376,13 +338,6 @@ private void Record(string id) /// /// A model that calls one tool by a name of the test's choosing, once, then answers with text. /// - /// - /// always calls a tool the document declares. This one calls - /// whatever name it is given, so a test can reproduce the model INVENTING a tool name — the - /// NotFound case, which the framework answers with a message and no exception, so it spends - /// none of the error budget and the turn goes on. Nothing recorded it before. - /// - /// Creates the client. /// The name the model calls. It need not be a name the document declares. /// What it says once the tool round is over. /// @@ -475,13 +430,6 @@ public void Dispose() /// Builds one REAL for each declared tool, and every one of them throws a /// fault the model cannot answer. /// - /// - /// builds a bare AIFunctionFactory delegate, which throws - /// straight at the framework. This one goes through the base every shipped tool kind shares, so it - /// exercises the classification AuditingFunctionInvokingChatClient applies and not just the - /// framework's reaction to it. Section 8.7 row six is only reachable through a tool that lets a fault - /// out. - /// internal sealed class UnreachableEndpointToolBuilder { /// The message every fault carries. @@ -521,11 +469,6 @@ private sealed class UnreachableEndpointTool(ToolConfiguration tool, Unreachable /// Builds one REAL for each declared tool, and every one of them throws a /// fault the model CAN answer. /// - /// - /// The counterpart of , and the half of section 8.7 that - /// must not regress: the tool answers with an error result, the model reads it, and the turn ends - /// with a spoken reply and no failure at all. - /// internal sealed class RefusedRequestToolBuilder { /// The message every fault carries. @@ -564,12 +507,6 @@ private sealed class RefusedRequestTool(ToolConfiguration tool, RefusedRequestTo /// /// A model that throws instead of answering, on both run shapes. /// - /// - /// R1 reaches the turn loop through a tool that failed four times, and that path needs a tool, a - /// looping model, and four round trips to set up. The fault this client raises is the same fault as - /// far as everything above the chat pipeline is concerned, so a test of the fallback layer says what - /// it means in three lines. - /// internal sealed class ThrowingChatClient(Exception fault) : IChatClient { private readonly Exception _fault = fault; @@ -605,11 +542,6 @@ public void Dispose() /// /// A model that calls a scripted list of tools by name, one per request, then answers in words. /// - /// - /// always calls the first tool it is offered, so it - /// cannot drive an agent whose behaviour IS which tool it picks next. This one names the tool and its arguments, which is - /// what makes a multi-hop test assert on the hops rather than on the count. - /// internal sealed class ScriptedToolCallingChatClient(params (string Tool, string Arguments)[] script) : IChatClient { private readonly (string Tool, string Arguments)[] _script = script; diff --git a/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs b/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs new file mode 100644 index 00000000..52040a66 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs @@ -0,0 +1,109 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Runtime +{ + // Only a conversation turn hands its running tools the end backstop in place of the caller's token. + public sealed class ToolCancelOutsideTurnTests + { + private const string BackgroundYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + defaults: { clock: false } + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [ worker ] } + - { id: worker, instructions: "quote the price", model: { ref: worker }, tools: [ price_lookup ] } + entries: + main: + agent: parent + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 20_000)] + public async Task ARunOutsideATurnStillCancelsItsRunningToolWhenTheCallerCancels() + { + GatedTool tool = new(); + AIAgent agent = HookSessions.Compile( + HookSessions.ToolAgentYaml, + new CueToolChatClient("F63", () => [], "ok"), + tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id))["main"].Agents["only"]; + AgentSession session = await agent.CreateSessionAsync(Ct); + using CancellationTokenSource caller = CancellationTokenSource.CreateLinkedTokenSource(Ct); + + Task run = agent.RunAsync("Max speed of the F63?", session, cancellationToken: caller.Token); + await tool.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + await caller.CancelAsync(); + + Task first = await Task.WhenAny(tool.Cancelled.Task, Task.Delay(TimeSpan.FromSeconds(3), Ct)); + bool cancelled = first == tool.Cancelled.Task; + tool.Release.TrySetResult(); + try + { + _ = await run; + } + catch (OperationCanceledException) + { + } + + Assert.True(cancelled, "the caller cancelled the run, yet the running tool was never cancelled"); + } + + // A background child outlives its turn, so its tool reads its own run's token, and the end backstop as well. + [Fact(Timeout = 20_000)] + public async Task ABackgroundChildsRunningToolIsCancelledThirtySecondsAfterTheConversationEnds() + { + DateTimeOffset start = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + FakeTimeProvider time = new(start); + GatedTool tool = new(); + ToolCallingChatClient parent = new( + "started", + new Dictionary(StringComparer.Ordinal) + { + ["agentName"] = "worker", + ["input"] = "F63", + ["description"] = "d", + }); + RoutingChatClientFactory models = new(parent); + _ = models.Route("parent", parent); + _ = models.Route("worker", new CueToolChatClient("F63", () => [], "done")); + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(BackgroundYaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(models) + { + Tools = TestToolRegistry.From(document, declared => tool.Create(declared.Id, declared.Description ?? declared.Id), Ct), + Clock = time, + })["main"]; + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards), timeProvider: time) + .Create("conversation-1"); + + _ = await session.RunTurnAsync("go", Ct); + await tool.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + await time.WaitForTimersAsync(start + ConversationEnding.ToolGrace, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + time.Advance(ConversationEnding.ToolGrace - TimeSpan.FromSeconds(1)); + Assert.False(tool.Cancelled.Task.IsCompleted); + time.Advance(TimeSpan.FromSeconds(1)); + + Task first = await Task.WhenAny(tool.Cancelled.Task, Task.Delay(TimeSpan.FromSeconds(3), Ct)); + bool cancelled = first == tool.Cancelled.Task; + tool.Release.TrySetResult(); + await session.DisposeAsync(); + + Assert.True(cancelled, "the conversation ended 30 s ago, yet the background child's tool still ran"); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnContextProviderTests.cs b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnContextProviderTests.cs index 8c762d4d..ec3aafea 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnContextProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnContextProviderTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.State; using AgentCore.Application.Tests.Fakes; @@ -11,23 +10,13 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Runtime.Turn { /// /// The per-invocation seam: what one turn hands the model on top of the agent's own instructions. /// - /// - /// - /// The compiled agent is a process singleton, so the provider bound to it is one too and may hold - /// nothing per conversation. It finds the turn in the session-keyed registry instead, and it applies what - /// it finds only to a run on the session that turn filed. That rule is what keeps a delegated - /// run — which is call and return on a session of its own — out of the turn's context. - /// - /// - /// Every test here runs offline: no network conversation and no API key. - /// - /// public sealed class TurnContextProviderTests { private const string ReminderYaml = diff --git a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnFilesTests.cs b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnFilesTests.cs index d67d78b3..c88a276c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnFilesTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnFilesTests.cs @@ -16,10 +16,7 @@ public void Publish_UnderACall_IsTakenByThatCallOnce() { TurnFiles files = new(); - using (files.BeginOuterCall("call-1")) - { - files.Publish(Card("rows.csv", 8)); - } + files.Publish(Card("rows.csv", 8), "call-1"); IReadOnlyList taken = files.TakeFor("call-1"); @@ -28,11 +25,11 @@ public void Publish_UnderACall_IsTakenByThatCallOnce() } [Fact] - public void Publish_WithNoCallOpen_IsDropped() + public void Publish_OutsideAnyCall_IsDropped() { TurnFiles files = new(); - files.Publish(Card("rows.csv", 8)); + files.Publish(Card("rows.csv", 8), callId: null); Assert.Empty(files.TakeFor("rows.csv")); } @@ -42,12 +39,9 @@ public void Publish_TheSameNameTwice_KeepsTheLaterFactsInTheFirstPlace() { TurnFiles files = new(); - using (files.BeginOuterCall("call-1")) - { - files.Publish(Card("rows.csv", 8)); - files.Publish(Card("chart.png", 3)); - files.Publish(Card("rows.csv", 16)); - } + files.Publish(Card("rows.csv", 8), "call-1"); + files.Publish(Card("chart.png", 3), "call-1"); + files.Publish(Card("rows.csv", 16), "call-1"); IReadOnlyList taken = files.TakeFor("call-1"); @@ -55,25 +49,6 @@ public void Publish_TheSameNameTwice_KeepsTheLaterFactsInTheFirstPlace() Assert.Equal(16, taken[0].Length); } - [Fact] - public void Publish_InsideANestedCall_FilesUnderTheOuterCall() - { - TurnFiles files = new(); - - using (files.BeginOuterCall("outer")) - { - using (files.BeginOuterCall("inner")) - { - files.Publish(Card("rows.csv", 8)); - } - - files.Publish(Card("chart.png", 3)); - } - - _ = Assert.Single(files.TakeFor("inner")); - _ = Assert.Single(files.TakeFor("outer")); - } - private static FileContent Card(string name, long length) { return new() { Name = name, FileId = name, MediaType = "text/csv", Length = length, Kept = true }; diff --git a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnResultsTests.cs b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnResultsTests.cs index 7cb01928..01780a53 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnResultsTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnResultsTests.cs @@ -1,11 +1,11 @@ using System.Text.Json; using System.Text.Json.Nodes; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Runtime.Turn { diff --git a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnSourcesTests.cs b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnSourcesTests.cs index de81924c..93cc3b97 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Turn/TurnSourcesTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Turn/TurnSourcesTests.cs @@ -8,10 +8,6 @@ namespace AgentCore.Application.Tests.Runtime.Turn /// /// What a turn has cited and not yet attached to a message. /// - /// - /// Keyed by the outer tool call for the same reason the render collector is: the message a source - /// belongs on is the result of the call that produced it, and a nested call must not steal it. - /// public sealed class TurnSourcesTests { [Fact] @@ -19,10 +15,7 @@ public void Publish_UnderACall_IsTakenByThatCall() { TurnSources sources = new(); - using (OpenCall(sources, "call-1")) - { - sources.Publish(Reference("card-1")); - } + sources.Publish(Reference("card-1"), "call-1"); IReadOnlyList taken = sources.TakeFor("call-1"); @@ -31,13 +24,13 @@ public void Publish_UnderACall_IsTakenByThatCall() } [Fact] - public void Publish_WithNoCallOpen_IsDropped() + public void Publish_OutsideAnyCall_IsDropped() { // mode: prefetch searches before any tool call exists. There is no message to attach to, so // the publish is dropped rather than attached to whatever message comes next. TurnSources sources = new(); - sources.Publish(Reference("card-1")); + sources.Publish(Reference("card-1"), callId: null); Assert.Empty(sources.TakeFor("call-1")); } @@ -49,11 +42,8 @@ public void Publish_TheSameIdTwice_IsShownOnce() // are noise, and the second publish is the fresher one. TurnSources sources = new(); - using (OpenCall(sources, "call-1")) - { - sources.Publish(Reference("card-1") with { Title = "first" }); - sources.Publish(Reference("card-1") with { Title = "second" }); - } + sources.Publish(Reference("card-1") with { Title = "first" }, "call-1"); + sources.Publish(Reference("card-1") with { Title = "second" }, "call-1"); SourceContent content = Assert.Single(sources.TakeFor("call-1")); Assert.Equal("second", content.Source.Title); @@ -64,10 +54,7 @@ public void TakeFor_TakesOnlyOnce() { TurnSources sources = new(); - using (OpenCall(sources, "call-1")) - { - sources.Publish(Reference("card-1")); - } + sources.Publish(Reference("card-1"), "call-1"); _ = Assert.Single(sources.TakeFor("call-1")); Assert.Empty(sources.TakeFor("call-1")); @@ -81,15 +68,9 @@ public void Publish_UnderTwoDifferentCalls_StampsEachSourceWithItsOwnCallId() // whichever call happens to be findable on that shared message afterward. TurnSources sources = new(); - using (OpenCall(sources, "call-1")) - { - sources.Publish(Reference("card-1")); - } + sources.Publish(Reference("card-1"), "call-1"); - using (OpenCall(sources, "call-2")) - { - sources.Publish(Reference("card-2")); - } + sources.Publish(Reference("card-2"), "call-2"); SourceContent fromCallOne = Assert.Single(sources.TakeFor("call-1")); Assert.Equal("call-1", fromCallOne.CallId); @@ -100,11 +81,6 @@ public void Publish_UnderTwoDifferentCalls_StampsEachSourceWithItsOwnCallId() Assert.Equal("card-2", fromCallTwo.Source.SourceId); } - private static IDisposable OpenCall(TurnSources sources, string callId) - { - return sources.BeginOuterCall(callId); - } - private static SourceReference Reference(string id) { return new() diff --git a/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs b/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs index e6d1296f..90048608 100644 --- a/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs @@ -1,14 +1,14 @@ using System.Diagnostics; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Diagnostics; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; namespace AgentCore.Application.Tests.Runtime { /// - /// Owner ruling on a row-six fault (docs/probes/r2/audit/AUDIT.md, finding F, PII table): the exception + /// On a turn fault, the exception /// TYPE goes on the agentcore.turn span, and the exception OBJECT -- message and stack trace both /// -- goes to the Error log. Neither carries the other's cut. /// @@ -23,10 +23,11 @@ public async Task AToolFaultsMarkerMessage_NeverReachesTheSpan_AndAlwaysReachesT using LoopingToolCallingChatClient reply = new(); ThrowingToolBuilder tools = new(); - ConversationSession session = Build(ToolYaml, reply, null, tools.Create, logger: logger) + ConversationSession session = TurnObservabilityHarness.Build(ToolYaml, reply, null, tools.Create, logger: logger) .Create("conversation-" + Guid.NewGuid().ToString("N")); _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); // ThrowingToolBuilder.Message is the marker here: a fixed literal nothing else in this run // produces, thrown inside a real TimeoutException by the tool itself. diff --git a/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs b/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs index 1bf5e599..5fee213c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs @@ -1,3 +1,5 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Tests.Audit; using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; @@ -6,7 +8,6 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Evaluation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Evaluation.Fakes; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; @@ -14,20 +15,14 @@ using Microsoft.Extensions.AI; using Xunit; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Runtime { /// /// Every audible turn is an ordinary successful run. /// - /// - /// - /// R1, R2 and R3 are two pipeline layers: ModerationAgent refuses a flagged turn before the model - /// runs, and FallbackAgent answers a run that threw or produced no text. Both report what they did - /// on a TurnDisposition, and the turn loop reads it to raise the turn's rows. - /// - /// Every test here runs offline. There is no network conversation and no API key in this file. - /// public sealed class TurnPipelineTests { private const string RefusalReply = "I am sorry. I cannot help with that request."; @@ -62,21 +57,16 @@ public sealed class TurnPipelineTests agent: only """; - // ------------------------------------------------------------------------------------------- - // R3 — moderation judges the caller, and a flagged turn never reaches the model. - // ------------------------------------------------------------------------------------------- [Fact] public async Task Run_FlaggedInput_CompletesWithRefusalReply() { - // Arrange. using SequencedChatClient model = new("never spoken"); ConversationSession session = Build(PlainYaml, model, moderation: ScriptedModerationEvaluator.Flagging("hate")) .Create("conversation-1"); - // Act. TurnResult turn = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - // Assert. The run succeeded, so nothing threw and the turn is not a failure. + // The run succeeded, so nothing threw and the turn is not a failure. Assert.Equal(RefusalReply, turn.ReplyText); Assert.Null(turn.Failure); Assert.Equal(0, model.Calls); @@ -85,19 +75,17 @@ public async Task Run_FlaggedInput_CompletesWithRefusalReply() [Fact] public async Task RunStreaming_FlaggedInput_CompletesWithRefusalReply() { - // Arrange. using SequencedChatClient model = new("never spoken"); ConversationSession session = Build(PlainYaml, model, moderation: ScriptedModerationEvaluator.Flagging("hate")) .Create("conversation-1"); - // Act. The refusal arrives on the ordinary stream path, not on a branch of its own. + // The refusal arrives on the ordinary stream path, not on a branch of its own. List spoken = []; await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("...", TestContext.Current.CancellationToken)) { spoken.Add(update.Text); } - // Assert. Assert.Equal(RefusalReply, string.Concat(spoken)); Assert.Equal(0, model.Calls); } @@ -105,33 +93,27 @@ public async Task RunStreaming_FlaggedInput_CompletesWithRefusalReply() [Fact] public async Task Run_FlaggedInput_InvokesNoTools() { - // Arrange. The model would call the tool if it ever ran. + // The model would call the tool if it ever ran. using ToolCallingChatClient model = new("never spoken"); StubToolBuilder tools = new(/*lang=json,strict*/ """{ "status": "shipped" }"""); ConversationSession session = Build(ToolYaml, model, tools: tools.Create, moderation: ScriptedModerationEvaluator.Flagging("hate")) .Create("conversation-1"); - // Act. _ = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - // Assert. The refusal is returned above the function-invoking loop, so no tool ran. + // The refusal is returned above the function-invoking loop, so no tool ran. Assert.Empty(model.Called); } - // ------------------------------------------------------------------------------------------- - // R1 and R2 — a failed turn and a quiet turn both speak the fallback. - // ------------------------------------------------------------------------------------------- [Fact] public async Task Run_ModelThrows_CompletesWithFallbackReply() { - // Arrange. using ThrowingChatClient model = new(new InvalidOperationException("the vendor is down")); ConversationSession session = Build(PlainYaml, model).Create("conversation-1"); - // Act. Nothing escapes: the layer below the agent caught it. + // Nothing escapes: the layer below the agent caught it. TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Assert. Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); Assert.NotNull(turn.Failure); Assert.Contains("the vendor is down", turn.Failure, StringComparison.Ordinal); @@ -141,22 +123,20 @@ public async Task Run_ModelThrows_CompletesWithFallbackReply() [Fact] public async Task Run_EmptyModelReply_CompletesWithFallbackReply() { - // Arrange. Request 41 goes out with no tools and returns quietly. + // Request 41 goes out with no tools and returns quietly. using SequencedChatClient model = new(" "); ConversationSession session = Build(PlainYaml, model).Create("conversation-1"); - // Act. TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Assert. Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, turn.Failure); + Assert.Equal(TurnFailureReasons.EmptyReply, turn.Failure); } [Fact] public async Task Run_ModerationEndpointDown_RunsTurnAndReportsUnavailable() { - // Arrange. A vendor outage must not refuse every caller on a support line. + // A vendor outage must not refuse every caller on a support line. InMemoryAuditSink sink = new(); using SequencedChatClient model = new("the ordinary reply"); ConversationSession session = Build( @@ -166,32 +146,26 @@ public async Task Run_ModerationEndpointDown_RunsTurnAndReportsUnavailable() moderation: ScriptedModerationEvaluator.Throwing(new InvalidOperationException("boom"))) .Create("conversation-1"); - // Act. TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Assert. The turn ran unchecked rather than being refused. + // The turn ran unchecked rather than being refused. Assert.Equal("the ordinary reply", turn.ReplyText); Assert.Equal(1, model.Calls); - Assert.DoesNotContain(sink.EventsOf("conversation-1"), entry => entry.Kind == AuditEventKind.PromptFlagged); + Assert.DoesNotContain(await session.RowsAsync(sink), entry => entry.Kind == AuditEventKind.PromptFlagged); } - // ------------------------------------------------------------------------------------------- - // The chain is unchanged. The layers moved; the rows did not. - // ------------------------------------------------------------------------------------------- [Fact] public async Task Run_FlaggedOutcome_RaisesSameAuditRowsAsBefore() { - // Arrange. InMemoryAuditSink sink = new(); ConversationSession session = Build(PlainYaml, new SequencedChatClient("never spoken"), sink: sink, moderation: ScriptedModerationEvaluator.Flagging("violence", "harassment")).Create("conversation-1"); - // Act. _ = await session.RunTurnAsync("...", TestContext.Current.CancellationToken); - // Assert. conversation.started, prompt.flagged, turn.completed — the flag still precedes the turn + // conversation.started, prompt.flagged, turn.completed — the flag still precedes the turn // event, because the verdict is known before the model runs. - IReadOnlyList events = sink.EventsOf("conversation-1"); + IReadOnlyList events = await session.RowsAsync(sink); Assert.Equal( [AuditEventKind.ConversationStarted, AuditEventKind.PromptFlagged, AuditEventKind.TurnCompleted], events.Select(entry => entry.Kind)); @@ -201,37 +175,30 @@ public async Task Run_FlaggedOutcome_RaisesSameAuditRowsAsBefore() [Fact] public async Task Run_ThrownOutcome_WritesNoToolFailedRow() { - // Arrange. InMemoryAuditSink sink = new(); using ThrowingChatClient model = new(new InvalidOperationException("the vendor is down")); ConversationSession session = Build(PlainYaml, model, sink: sink).Create("conversation-1"); - // Act. TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Assert. The model threw, not a tool, so the turn fails as a run fault and names no tool. - IReadOnlyList events = sink.EventsOf("conversation-1"); + // The model threw, not a tool, so the turn fails as a run fault and names no tool. + IReadOnlyList events = await session.RowsAsync(sink); Assert.Equal( [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], events.Select(entry => entry.Kind)); Assert.Equal("the turn's run faulted, so it spoke the fallback. the vendor is down", turn.Failure); } - // ------------------------------------------------------------------------------------------- - // Where the marker is readable. The read site differs by run shape, and that is measured. - // ------------------------------------------------------------------------------------------- [Fact] public async Task Run_BufferedTurn_TurnDispositionReadableOnResponse() { - // Arrange. using SequencedChatClient model = new("the ordinary reply"); CompiledAgent compiled = Compile(PlainYaml, model, out _, moderation: ScriptedModerationEvaluator.Clean()); - // Act. AgentResponse response = await compiled.TurnAgent.RunAsync( "hello", cancellationToken: TestContext.Current.CancellationToken); - // Assert. The marker rides the run itself, so nothing about the reply is disturbed. + // The marker rides the run itself, so nothing about the reply is disturbed. AdditionalPropertiesDictionary? properties = response.AdditionalProperties; Assert.NotNull(properties); Assert.True(AdditionalPropertiesExtensions.TryGetValue(properties, out TurnDisposition? disposition)); @@ -241,11 +208,9 @@ public async Task Run_BufferedTurn_TurnDispositionReadableOnResponse() [Fact] public async Task RunStreaming_CleanTurn_MarkerOnLeadingUpdateAddsNoText() { - // Arrange. using SequencedChatClient model = new("the ordinary reply"); CompiledAgent compiled = Compile(PlainYaml, model, out _, moderation: ScriptedModerationEvaluator.Clean()); - // Act. List updates = []; await foreach (AgentResponseUpdate update in compiled.TurnAgent.RunStreamingAsync( "hello", cancellationToken: TestContext.Current.CancellationToken)) @@ -253,7 +218,7 @@ public async Task RunStreaming_CleanTurn_MarkerOnLeadingUpdateAddsNoText() updates.Add(update); } - // Assert. The marker rides an update with empty contents, so the caller's audio is untouched. + // The marker rides an update with empty contents, so the caller's audio is untouched. Assert.Equal("the ordinary reply", string.Concat(updates.Select(update => update.Text))); Assert.Contains( updates, @@ -261,22 +226,18 @@ public async Task RunStreaming_CleanTurn_MarkerOnLeadingUpdateAddsNoText() && AdditionalPropertiesExtensions.Contains(properties)); } - // ------------------------------------------------------------------------------------------- - // The layers wrap the agent a TURN runs, and never a graph node. - // ------------------------------------------------------------------------------------------- [Fact] public async Task Run_GraphRow_ModeratesTheCallerOnceAndNotOncePerNode() { - // Arrange. Two nodes run for one turn. + // Two nodes run for one turn. using SequencedChatClient researcher = new("Let me check the order system."); using SequencedChatClient responder = new("Order 41 ships Friday."); ScriptedModerationEvaluator endpoint = ScriptedModerationEvaluator.Clean(); ConversationSession session = BuildGraph(researcher, responder, endpoint).Create("conversation-1"); - // Act. _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - // Assert. R3 is a rule about a turn, and one turn is one run of one agent on every row. + // Moderation is a rule about a turn, and one turn is one run of one agent on every row. Assert.Equal(["where is my order"], endpoint.Moderated); Assert.Equal(1, researcher.Calls); Assert.Equal(1, responder.Calls); @@ -285,25 +246,21 @@ public async Task Run_GraphRow_ModeratesTheCallerOnceAndNotOncePerNode() [Fact] public async Task Run_GraphRowWhereNoNodeSpeaks_SpeaksTheFallbackOnceForTheTurn() { - // Arrange. Every node runs and none of them produces a word. + // Every node runs and none of them produces a word. using SequencedChatClient researcher = new(" "); using SequencedChatClient responder = new(" "); ConversationSession session = BuildGraph(researcher, responder, moderation: null).Create("conversation-1"); - // Act. TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - // Assert. R2 is a rule about a turn: one fallback is spoken to the caller, and none of it is + // The fallback is a rule about a turn: one fallback is spoken to the caller, and none of it is // fed back into the graph as a node reply. Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, turn.Failure); + Assert.Equal(TurnFailureReasons.EmptyReply, turn.Failure); Assert.Equal(1, researcher.Calls); Assert.Equal(1, responder.Calls); } - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- private const string GraphYaml = """ apiVersion: agentcore/v1 @@ -336,7 +293,7 @@ private static ConversationSessionFactory BuildGraph( return new ConversationSessionFactory( compiled, new GuardEvaluator(compiled.Configuration.Guards), - observers: ConversationObservers.Standard(new InMemoryAuditSink(), logger: null)); + hooks: BuiltInHooks.Create(new InMemoryAuditSink())); } private static CompiledAgent Compile( @@ -366,11 +323,12 @@ private static ConversationSessionFactory Build( ScriptedModerationEvaluator? moderation = null) { CompiledAgent compiled = Compile(yaml, reply, out _, tools, moderation); + IAuditSinkPort rows = sink ?? new InMemoryAuditSink(); return new ConversationSessionFactory( compiled, new GuardEvaluator(compiled.Configuration.Guards), - observers: ConversationObservers.Standard(sink ?? new InMemoryAuditSink(), logger: null)); + hooks: BuiltInHooks.Create(rows)); } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/TurnProbeAgent.cs b/tests/AgentCore.Application.Tests/Runtime/TurnProbeAgent.cs new file mode 100644 index 00000000..9214a538 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/TurnProbeAgent.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using AgentCore.Application.Runtime.Turn; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Records the turn its run finds on the options and on its session. + internal sealed class TurnProbeAgent : AIAgent + { + public TurnInvocation? FromOptions { get; private set; } + + public TurnInvocation? FromSession { get; private set; } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + return new(new Session()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + FromOptions = TurnInvocation.From(CurrentRunContext?.RunOptions); + FromSession = TurnRegistry.For(session); + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + /// A session that carries nothing; the registry keys on its identity. + internal sealed class Session : AgentSession; + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs index abd4ffa8..28813881 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; @@ -11,6 +10,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions @@ -177,7 +177,7 @@ public async Task TheSessionUnloadsWithinOnePollAfterTheIdleTimeoutOnceTheChildE [Fact] public async Task AChildThatEndsWellBeforeTheIdleTimeoutStillCountsAsActivity() { - // Issue #31 fix round, defect 2: the child is released long before the idle timeout comes due at + // The child is released long before the idle timeout comes due at // all. A check only at the deadline would find it already gone and expire right there, on schedule, // as if the child had never run. The document is polled through the whole window instead, so the // child's end (not the original deadline) is what the idle timeout counts from. @@ -208,7 +208,7 @@ public async Task AChildThatEndsWellBeforeTheIdleTimeoutStillCountsAsActivity() clock.Advance(HeldSession.BackgroundChildPollInterval); // The moment the original idle timeout, counted from session open rather than from the child's end, - // would have expired the session under the old bug. It must still be held. + // would have expired the session. It must still be held. TimeSpan sinceOpen = childReleasedAt + HeldSession.BackgroundChildPollInterval; clock.Advance(IdleTimeout - sinceOpen); Assert.Equal(1, sessions.Count); @@ -223,7 +223,7 @@ public async Task AChildThatEndsWellBeforeTheIdleTimeoutStillCountsAsActivity() [Fact] public async Task TheBackgroundPollIntervalIsClampedToTheIdleTimeout() { - // Issue #31 fix round, defect 4: an idle timeout shorter than the poll interval must not let the + // An idle timeout shorter than the poll interval must not let the // session outlive its own timeout just because nothing polled it in time. FakeTimeProvider clock = Clock(); TimeSpan shortIdleTimeout = TimeSpan.FromSeconds(5); @@ -243,12 +243,11 @@ public async Task TheBackgroundPollIntervalIsClampedToTheIdleTimeout() [Fact] public async Task AFaultReadingWhetherAChildIsRunningIsTreatedAsOneStillRunningAndLogged() { - // Issue #31 fix round, defect 1: GetIncompleteTasks can throw. A state bag entry set through a live - // SetValue call always short-circuits to a CLR type check (proven while chasing this down: a - // mismatched cached type returns false, never touching JSON), so the only path that reaches a real + // GetIncompleteTasks can throw. A state bag entry set through a live + // SetValue call always short-circuits to a CLR type check, so the only path that reaches a real // JsonException is a session resumed from stored JSON, where a key comes back shaped for a // different provider version. Thrown on the idle timer thread with no catch, this either crashes the - // process (TimeProvider.System) or leaves the timer never re-armed (a fake clock): reviewer-proven. + // process (TimeProvider.System) or leaves the timer never re-armed (a fake clock). RecordingLoggerFactory logs = new(); ConversationSessionFactory factory = BuildFactory(new GatedChatClient(new ScriptedChatClient("done")), logs.CreateLogger("session")); diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs index f1712cd6..59cacf7b 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs @@ -5,7 +5,6 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime.Harness; @@ -13,6 +12,7 @@ using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions @@ -179,13 +179,12 @@ public async Task TwoEntriesRacingToOpenOneIdHoldTheFirstToReserveItAndTheRefuse { // Every session writes conversation.started as it is built, so a loser built and then thrown away // would leave a start in the winner's audit chain that no conversation ever had. - RecordingConversationObserver observer = new(); InMemoryConversationStore store = new(); using GatedSessionFactory phoneFactory = new( - BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply"), store, _root, observer)); + BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply"), store, _root)); using GatedSessionFactory chatFactory = new( - BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply"), store, _root, observer)); + BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply"), store, _root)); chatFactory.Release(); using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); @@ -202,7 +201,6 @@ public async Task TwoEntriesRacingToOpenOneIdHoldTheFirstToReserveItAndTheRefuse ConversationSession phone = await phoneOpen; Assert.Equal(0, chatFactory.Calls); - Assert.Single(observer.Kinds, kind => kind == ConversationEventKind.ConversationStarted); Assert.Same(phone, await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); Assert.Null(await sessions.TryGetAsync(ChatEntry, "conversation-1", Token)); Assert.Equal(1, sessions.Count); @@ -266,8 +264,7 @@ private static ConversationSessionFactory BuildFactory( string entry, IChatClient reply, IConversationStore? store = null, - string? workspaceRoot = null, - IConversationObserver? observer = null) + string? workspaceRoot = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); RoutingChatClientFactory chatClients = new(reply); @@ -278,7 +275,6 @@ private static ConversationSessionFactory BuildFactory( return new ConversationSessionFactory( compiled, new GuardEvaluator(compiled.Configuration.Guards), - observers: observer is null ? null : [observer], workspaceRoot: workspaceRoot); } diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs index 498e8d12..bb02e4ad 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs @@ -1,11 +1,12 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions @@ -62,7 +63,7 @@ public async Task ALiveSessionIsReturnedWithItsOwnStateNotTheOnePassedIn() Main, "conversation-1", new ConversationSessionState { NextTurnIndex = 41 }, Token); Assert.Same(opened, again); - Assert.Equal(7, again.Snapshot().NextTurnIndex); + Assert.Equal(7, again.States.Snapshot().NextTurnIndex); } [Fact] diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs index 8e9494f1..d8a76869 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs @@ -1,9 +1,10 @@ -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Runtime; using AgentCore.TestSupport; using AgentCore.Domain; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions @@ -44,15 +45,16 @@ public async Task AnExpiringSessionWritesNoConversationEndedEventAndTheSessionIs { // An idle unload frees the session's resources without ending the conversation: no // conversation.ended event, so the conversation stays open in the store for a caller to resume. - RecordingConversationObserver observer = new(); + RecordingHook hook = new(); FakeTimeProvider clock = Clock(); - using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(observer: observer)), IdleTimeout, clock); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(hook: hook)), IdleTimeout, clock); ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); clock.Advance(IdleTimeout); await EventuallyAsync(() => IsDisposed(session)); - Assert.DoesNotContain(ConversationEventKind.ConversationEnded, observer.Kinds); + _ = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Token); + Assert.Empty(hook.Of()); } [Fact] @@ -143,7 +145,7 @@ public async Task ATurnRunningPastTheIdleTimeoutStampsTheWorkspaceOnceItCrossesT [Fact] public async Task AStuckTurnInOneSessionDoesNotStopOtherSessionsFromExpiring() { - // Review finding R5-2. Closing a session waits for its running turn, so a close that one session + // Closing a session waits for its running turn, so a close that one session // cannot finish must hold that session only. FakeTimeProvider clock = Clock(); using HangUntilCancelledChatClient model = new(); diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs index 2323d667..56324689 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs @@ -1,8 +1,8 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Transcript; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions @@ -59,11 +59,13 @@ public async Task HeartbeatKeepsStampingAcrossTwoLongTurnsInTurnThenTheSessionEx Assert.Equal(1, sessions.Count); } + // The release touch runs on a continuation after the turn frees its slot, not inside the turn, + // so it is waited for by its own stamp: one the last heartbeat could not have written already. + clock.Advance(TimeSpan.FromMinutes(1)); + DateTime freedAt = clock.GetUtcNow().UtcDateTime; gates[t].SetResult(); await turn; - DateTime freedAt = clock.GetUtcNow().UtcDateTime; await EventuallyAsync(() => Stamp("c1") == freedAt); - clock.Advance(TimeSpan.FromMinutes(1)); } clock.Advance(IdleTimeout); diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs index 97171cdd..0636a80e 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs @@ -1,6 +1,6 @@ -using AgentCore.Application.Runtime; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs index b93a3771..b318a432 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs @@ -2,12 +2,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Sessions { @@ -34,7 +35,7 @@ internal static FakeTimeProvider Clock() internal static ConversationSessionFactory Factory( IConversationStore? transcript = null, IChatClient? reply = null, - IConversationObserver? observer = null, + AgentHook? hook = null, string? workspaceRoot = null, TimeProvider? timeProvider = null) { @@ -49,8 +50,8 @@ internal static ConversationSessionFactory Factory( new GuardEvaluator(compiled.Configuration.Guards), ConversationSessionFactory.CreateExtractor(compiled, chatClients), timeProvider: timeProvider, - observers: observer is null ? null : [observer], - workspaceRoot: workspaceRoot); + workspaceRoot: workspaceRoot, + hooks: hook is null ? null : [hook]); } /// Waits for a condition that background work makes true. diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs index d0b5f662..08fb2f28 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs @@ -1,8 +1,8 @@ using AgentCore.TestSupport; using AgentCore.Application.Sessions.Memory; -using AgentCore.Application.Runtime; using AgentCore.Domain; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions diff --git a/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs b/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs index b7d41fbf..4572727f 100644 --- a/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs +++ b/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Sessions { diff --git a/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs b/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs index 4237106a..a75a904a 100644 --- a/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs @@ -1,7 +1,7 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions diff --git a/tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs b/tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs deleted file mode 100644 index 7988bd86..00000000 --- a/tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs +++ /dev/null @@ -1,41 +0,0 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; - -namespace AgentCore.Application.Tests.Sessions -{ - /// Records the kind of every event one conversation raised. - internal sealed class RecordingConversationObserver : IConversationObserver - { - private readonly List _kinds = []; - private readonly TaskCompletionSource _ended = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public IReadOnlyList Kinds - { - get - { - lock (_kinds) - { - return [.. _kinds]; - } - } - } - - /// Gets a task that completes once conversation.ended arrives. - public Task Ended => _ended.Task; - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken = default) - { - lock (_kinds) - { - _kinds.Add(conversationEvent.Kind); - } - - if (conversationEvent.Kind == ConversationEventKind.ConversationEnded) - { - _ = _ended.TrySetResult(); - } - - return ValueTask.CompletedTask; - } - } -} diff --git a/tests/AgentCore.Application.Tests/State/StateDocumentTests.cs b/tests/AgentCore.Application.Tests/State/StateDocumentTests.cs index a6b76f0b..a59f7c5e 100644 --- a/tests/AgentCore.Application.Tests/State/StateDocumentTests.cs +++ b/tests/AgentCore.Application.Tests/State/StateDocumentTests.cs @@ -106,7 +106,7 @@ public void WrittenSlots_HandsOutAFreshCopyEveryConversation() IReadOnlyDictionary first = state.WrittenSlots(); IReadOnlyDictionary second = state.WrittenSlots(); - // The declared state schema only ever writes scalars (section 8.3's four writers coerce + // The declared state schema only ever writes scalars (the four state writers coerce // to boolean/integer/number/string), so a scalar JsonValue is the only value shape this // slot can hold today, and a scalar exposes no public mutator. The clone's contract is // still checked the way a caller could actually break it without one: two conversations must not @@ -138,9 +138,8 @@ public void WrittenSlots_OmitsASlotNoWriterHasFilled() public async Task WrittenSlots_ReadWhileTheTurnLoopIsStillWriting_AnswersInsteadOfThrowing() { // The turn loop is this document's only WRITER, but it is not its only toucher. - // ConversationSession.Snapshot reads it off the turn, and AgentCoreAgent.SerializeSessionCoreAsync is - // a framework seam any host thread may conversation while a turn is running — an exposure this branch - // created, because that seam used to throw NotSupportedException instead of answering. + // ConversationSessionStateStore.Snapshot reads it off the turn, and AgentCoreAgent.SerializeSessionCoreAsync is + // a framework seam any host thread may call while a turn is running. // Enumerating a plain Dictionary mid-write is an InvalidOperationException, and it would // surface out of the framework's own serialization API. AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ManySlotsYaml); @@ -158,7 +157,7 @@ public async Task WrittenSlots_ReadWhileTheTurnLoopIsStillWriting_AnswersInstead while (!written.IsCancellationRequested) { // A torn read is fine and is the accepted price: the snapshot is best effort - // by D5, and the next turn's write corrects it. A throw is not fine. + // by design, and the next turn's write corrects it. A throw is not fine. _ = state.WrittenSlots(); } }, diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs index ffaaddf8..3f1da598 100644 --- a/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools; @@ -7,6 +6,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Tools { @@ -14,11 +14,6 @@ namespace AgentCore.Application.Tests.Tools /// A background child runs with no turn, so file.publish finds the owning conversation on the stamp /// its session carries and stores the file under the parent conversation. /// - /// - /// The child session is created inside the parent's run, the way BackgroundAgentsProvider - /// creates it, so stamps it. The child then runs on that session - /// with null run options, which is what the provider passes it. - /// public sealed class FilePublishBackgroundChildTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "agentcore-publish-child-" + Guid.NewGuid().ToString("N")); @@ -36,7 +31,6 @@ public void Dispose() [Fact] public async Task AChildThatPublishes_StoresTheFileUnderTheParentConversation() { - // Arrange CancellationToken token = TestContext.Current.CancellationToken; File.WriteAllText(Path.Combine(_root, "conversation-9", "rows.csv"), "a,b"); RecordingBlobStore blobs = new(); @@ -56,10 +50,9 @@ public async Task AChildThatPublishes_StoresTheFileUnderTheParentConversation() _ = await parent.RunAsync("go", await parent.CreateSessionAsync(token), turn.RunOptions(), token); - // Act: the child runs on its stamped session with no run options, as the provider runs it. + // The child runs on its stamped session with no run options, as the provider runs it. _ = await child.RunAsync("publish the rows", creating.Created!, options: null, token); - // Assert Assert.Equal(["publish"], childModel.Called); string result = Assert.Single(childModel.ToolResults); Assert.Contains("https://blobs.test/conversation-9/rows.csv", result, StringComparison.Ordinal); diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs new file mode 100644 index 00000000..1e61988c --- /dev/null +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs @@ -0,0 +1,234 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Tools.Builtin; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Tools +{ + /// + /// A file any agent of the turn publishes is the caller's. A graph participant files its cards in a collector of + /// its own and posts them to the caller as its run goes, around the workflow, so a card reaches the caller even + /// from a participant that does not speak. An agent-as-tool child's card rides its parent's call. + /// + public sealed class FilePublishGraphParticipantTests : IDisposable + { + private const string SequentialYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: publish, kind: builtin, uses: file.publish, description: "Publish a file." } + agents: + defaults: { clock: false } + items: + - { id: publisher, instructions: "publish the rows", model: { ref: publisher }, tools: [ publish ], todos: true } + - { id: responder, instructions: "answer", model: { ref: responder } } + entries: + main: + graph: + pattern: sequential + agents: [ publisher, responder ] + """; + + private const string ConcurrentYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: publish, kind: builtin, uses: file.publish, description: "Publish a file." } + agents: + defaults: { clock: false } + items: + - { id: a, instructions: "publish a", model: { ref: a }, tools: [ publish ] } + - { id: b, instructions: "publish b", model: { ref: b }, tools: [ publish ] } + entries: + main: + graph: + pattern: concurrent + agents: [ a, b ] + """; + + private const string DelegatingYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: publish, kind: builtin, uses: file.publish, description: "Publish a file." } + - { id: ask_helper, kind: agent, agent: helper, description: "Ask the helper." } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "delegate", tools: [ ask_helper ] } + - { id: helper, instructions: "publish the rows", tools: [ publish ] } + entries: + main: + agent: only + """; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "agentcore-graph-publish-" + Guid.NewGuid().ToString("N")); + + private readonly RecordingBlobStore _blobs = new(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + // The publisher does not speak (a sequential row answers from its last agent): its file still reaches the + // caller, while its words and its tool call and result do not. todos: makes the row keep its workflow session, + // so the turn also ends by serializing that session and every participant's: none may hold the file. + [Fact] + public async Task AFilePublishedByAParticipantThatDoesNotSpeakReachesTheCallerAlone() + { + RoutingChatClientFactory models = new(); + _ = models.Route("publisher", new ToolCallingChatClient("publisher words", PathArgument("rows.csv"))); + _ = models.Route("responder", new ScriptedChatClient("responder words")); + RecordingHook notices = new(); + ConversationSession session = Create(SequentialYaml, models, notices); + + List updates = await StreamAsync(session); + await session.FlushNoticesAsync(); + + Assert.Equal(["rows.csv"], updates.SelectMany(static update => update.Contents.OfType()).Select(static file => file.Name)); + Assert.Equal("responder words", string.Concat(updates.Select(static update => update.Text))); + Assert.DoesNotContain(updates, static update => update.Contents.Any(static content => content is FunctionCallContent or FunctionResultContent)); + Assert.Equal([("conversation-1", "rows.csv")], _blobs.Blobs.Keys); + _ = Assert.Single(notices.Of()); + } + + // The stored shape of a graph turn is the words the caller was shown; the file card the caller was handed + // is part of that, as it is for a single agent's turn: the stored list names the file, and the card is + // still in the stored messages after a reload. + [Fact] + public async Task AFilePublishedInAGraphTurnIsInTheStoredFileListAndItsCardSurvivesAReload() + { + RoutingChatClientFactory models = new(); + _ = models.Route("publisher", new ToolCallingChatClient("publisher words", PathArgument("rows.csv"))); + _ = models.Route("responder", new ScriptedChatClient("responder words")); + InMemoryConversationStore store = new(); + ConversationSession session = Create(SequentialYaml, models, new RecordingHook(), store: store); + + _ = await StreamAsync(session); + await session.FlushTranscriptAsync(); + + StoredConversation? stored = await new Conversations(store, _blobs) + .LoadWindowAsync("conversation-1", new TranscriptWindow(null, 10), Ct); + + Assert.NotNull(stored); + Assert.Equal(["rows.csv"], stored.Files.Select(static link => link.Blob.Name)); + Assert.Equal( + ["rows.csv"], + stored.Messages.SelectMany(static message => message.Content.Contents).OfType().Select(static file => file.Name)); + Assert.Equal("responder words", stored.Messages[^1].Content.Text); + } + + [Fact] + public async Task AFilePublishedByAnAgentToolChildReachesTheCallerOnce() + { + ScriptedToolCallingChatClient model = new(("ask_helper", """{ "query": "publish the rows" }"""), ("publish", """{ "path": "rows.csv" }""")) + { + FinalText = "published", + }; + RecordingHook notices = new(); + ConversationSession session = Create(DelegatingYaml, new RoutingChatClientFactory(model), notices); + + List updates = await StreamAsync(session); + await session.FlushNoticesAsync(); + + Assert.Equal(["rows.csv"], updates.SelectMany(static update => update.Contents.OfType()).Select(static file => file.Name)); + Assert.Equal([("conversation-1", "rows.csv")], _blobs.Blobs.Keys); + _ = Assert.Single(notices.Of()); + } + + // Two participants publish while both calls are in flight. Each card must name its own participant. + [Fact(Timeout = 60_000)] + public async Task ConcurrentParticipantsEachGetTheirOwnFileEveryTime() + { + for (int run = 0; run < 10; run++) + { + RoutingChatClientFactory models = new(); + _ = models.Route("a", new ToolCallingChatClient("a done", PathArgument("a.csv"))); + _ = models.Route("b", new ToolCallingChatClient("b done", PathArgument("b.csv"))); + RecordingHook notices = new(); + ConversationSession session = Create(ConcurrentYaml, models, notices, meetingOf: 2, $"conversation-{run}"); + + List updates = await StreamAsync(session); + await session.FlushNoticesAsync(); + + Assert.Equal(TurnOutcome.Answered, Assert.Single(notices.Of()).Outcome); + + Assert.Equal( + [("a", "a.csv"), ("b", "b.csv")], + updates + .SelectMany(static update => update.Contents.OfType().Select(file => (update.AuthorName, file.Name))) + .OrderBy(static card => card.AuthorName, StringComparer.Ordinal)); + } + } + + private static Dictionary PathArgument(string path) + { + return new(StringComparer.Ordinal) { ["path"] = path }; + } + + private ConversationSession Create( + string yaml, RoutingChatClientFactory models, RecordingHook notices, int meetingOf = 0, string conversationId = "conversation-1", IConversationStore? store = null) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(models) + { + Tools = TestToolRegistry.From(document, Publish(meetingOf), Ct), + Hooks = [notices], + ConversationStore = store, + })["main"]; + + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards), workspaceRoot: _root) + .Create(conversationId); + foreach (string name in (string[])["rows.csv", "a.csv", "b.csv"]) + { + File.WriteAllText(Path.Combine(session.Workspace!, name), "a,b\n1,2\n"); + } + + return session; + } + + /// Builds the real file.publish, held until calls are in flight when it is above 0. + private Func Publish(int meetingOf) + { + return tool => + { + if (tool.Kind != ToolKind.Builtin) + { + return null; + } + + AIFunction publish = (AIFunction)new FilePublishToolDefinition().Build( + tool, new BuiltinToolPorts(ChatClients: null, Blobs: _blobs, WorkspaceRoot: _root)); + return meetingOf == 0 ? publish : new MeetingFunction(publish, meetingOf); + }; + } + + private static async Task> StreamAsync(ConversationSession session) + { + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("publish it", Ct)) + { + updates.Add(update); + } + + return updates; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs index e23f68b7..d77fffbb 100644 --- a/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Blobs; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools; @@ -131,7 +130,6 @@ public async Task Publish_AGoodFile_StoresItUnderTheConversationLinksItAndFilesT { File.WriteAllText(Path.Combine(_root, "conversation-1", "rows.csv"), "a,b\n1,2\n"); TurnFiles files = new(); - using IDisposable outer = files.BeginOuterCall("tc_1"); JsonObject result = await PublishAsync("rows.csv", title: "Sales by month", files: files); @@ -200,6 +198,7 @@ private async Task PublishAsync( Stage = string.Empty, Workspace = Path.Combine(_root, "conversation-1"), Files = files, + OuterCallId = "tc_1", }.FileIn(arguments); } diff --git a/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs b/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs new file mode 100644 index 00000000..43fc1596 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Tools +{ + /// Holds every call until a given number of calls are in flight at once, then lets them all run. + internal sealed class MeetingFunction(AIFunction inner, int parties) : DelegatingAIFunction(inner) + { + private readonly TaskCompletionSource _met = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrived; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _arrived) >= parties) + { + _met.TrySetResult(); + } + + await _met.Task.WaitAsync(cancellationToken); + return await base.InvokeCoreAsync(arguments, cancellationToken); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs index 355e922c..3a5786f8 100644 --- a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs @@ -3,13 +3,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; using AgentCore.Application.Tools.Registry; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Tools { diff --git a/tests/AgentCore.Application.Tests/Tools/TypedBindingToolTests.cs b/tests/AgentCore.Application.Tests/Tools/TypedBindingToolTests.cs index 76af6556..9b51c100 100644 --- a/tests/AgentCore.Application.Tests/Tools/TypedBindingToolTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/TypedBindingToolTests.cs @@ -3,12 +3,13 @@ using System.Text.Json.Nodes; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; using AgentCore.Application.Tools.Registry; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.ToolCalls; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Tests.Tools { @@ -16,10 +17,6 @@ namespace AgentCore.Application.Tests.Tools /// The typed half of kind: binding: the host registers a method, and its signature is the /// argument schema the model reads. /// - /// - /// The document writes binds: CreateCase and no parameters:. Everything the model - /// needs to fill the conversation comes off the method, so the two can never drift apart. - /// public sealed class TypedBindingToolTests { private static readonly ToolConfiguration OpenCase = new() @@ -236,7 +233,7 @@ public async Task AToolCallScopeParameterWithTheTurnFiled_BindsWithoutAFlow() Assert.Equal("ws", captured.Workspace); } - /// The error policy of section 8.7 keys off , not off the delegate. + /// The error policy keys off , not off the delegate. [Fact] public async Task ATypedBinding_IsStillADeclaredTool() { diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs index eaaf178e..c57c4432 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; @@ -12,13 +11,14 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; using static AgentCore.Application.Tests.Transcript.AgentCoreChatHistoryProviderTestSupport; namespace AgentCore.Application.Tests.Transcript { /// - /// The provider as the only door to store 1: the framework's hook stages, and CommitTurn and - /// RewriteReply are the durable writes (docs/handoff/2026-09-22-maf-native-engine-design.md). + /// The provider as the only door to the message store: the framework's hook stages, and CommitTurn and + /// RewriteReply are the durable writes. /// public sealed class AgentCoreChatHistoryProviderCommitTests { @@ -51,33 +51,31 @@ public sealed class AgentCoreChatHistoryProviderCommitTests private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Design section 7 item 1 (owner ruling): a loop: entry calls the hook once per iteration inside one + // A loop: entry calls the hook once per iteration inside one // turn; staging accumulates and the commit writes all of it once. maxRounds: 3 with an open todo runs // three iterations (LoopCompilationTests), each a call, its result and a line. [Fact] public async Task CommitTurn_LoopEntry_StagesEveryIteration_AndWritesThemInOneAppend() { - // Arrange RecordingConversationStore store = new(); ToolCallingChatClient client = new( "added", new Dictionary(StringComparer.Ordinal) { ["todos"] = new object[] { new Dictionary(StringComparer.Ordinal) { ["title"] = "T" } }, - }); + }, + everyRun: true); CompiledAgent compiled = ConfigurationCompiler.CompileAll( ConfigurationLoader.LoadYaml(LoopYaml), new AgentCompilationContext(new FakeChatClientFactory(client)) { ConversationStore = store })["main"]; AIAgent agent = compiled.Agents["coder"]; AgentSession session = await OpenAsync(compiled, agent, store); - // Act _ = await agent.RunAsync("fix it", session, cancellationToken: Ct); IReadOnlyList staged = compiled.History.Staged(session); _ = compiled.History.CommitTurn(session, new TurnCommit(new ChatMessage(ChatRole.User, "fix it"))); await compiled.History.DrainAsync(session); - // Assert string[] iteration = ["assistant:call", "tool:result", "assistant:text"]; Assert.Equal([.. iteration, .. iteration, .. iteration], Kinds(staged)); Assert.Equal(1, store.Appends); @@ -85,12 +83,11 @@ public async Task CommitTurn_LoopEntry_StagesEveryIteration_AndWritesThemInOneAp Assert.Empty(compiled.History.Staged(session)); } - // Design section 7 item 1 (owner ruling): an approval re-entry runs the agent again inside the same + // An approval re-entry runs the agent again inside the same // turn. The first run ends on the approval request, the second runs the tool and answers "done.". [Fact] public async Task CommitTurn_ApprovalReEntry_StagesBothRuns_AndWritesThemInOneAppend() { - // Arrange RecordingConversationStore store = new(); ToolCallingChatClient client = new("done.", new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" }); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(AutoApprovedYaml); @@ -104,13 +101,11 @@ public async Task CommitTurn_ApprovalReEntry_StagesBothRuns_AndWritesThemInOneAp AIAgent agent = compiled.Agents["only"]; AgentSession session = await OpenAsync(compiled, agent, store); - // Act _ = await agent.RunAsync("send it", session, cancellationToken: Ct); IReadOnlyList staged = compiled.History.Staged(session); _ = compiled.History.CommitTurn(session, new TurnCommit(new ChatMessage(ChatRole.User, "send it"))); await compiled.History.DrainAsync(session); - // Assert Assert.Contains(staged.SelectMany(message => message.Contents), content => content is ToolApprovalRequestContent); Assert.Equal("done.", staged[^1].Text); Assert.Equal(1, store.Appends); @@ -118,11 +113,10 @@ public async Task CommitTurn_ApprovalReEntry_StagesBothRuns_AndWritesThemInOneAp Assert.Equal("done.", store.Rows[^1].Content.Text); } - // Probe G1: an AIContextProvider's system line reaches the model, and never the committed turn. + // An AIContextProvider's system line reaches the model, and never the committed turn. [Fact] public async Task CommitTurn_ContextProviderSystemLine_ReachesTheModelButNotTheTranscript() { - // Arrange (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession _) = await NewConversation(); RequestRecordingChatClient model = new("x"); ChatClientAgent agent = new( @@ -132,22 +126,19 @@ public async Task CommitTurn_ContextProviderSystemLine_ReachesTheModelButNotTheT _ = provider.BeginConversation(session, ConversationId, []); provider.BeginTurn(session, 0); - // Act _ = await agent.RunAsync("hi", session, cancellationToken: Ct); _ = provider.CommitTurn(session, new TurnCommit(new ChatMessage(ChatRole.User, "hi"))); await provider.DrainAsync(session); - // Assert Assert.Contains("system:Today is Tuesday.", model.Requests[0]); Assert.Equal(["user:hi", "assistant:x"], store.Rows.Select(row => $"{row.Content.Role}:{row.Content.Text}")); } - // Probe G4: a background child carries the parent's TurnRegistry entry and BlobOwnerKey, but not the + // A background child carries the parent's TurnRegistry entry and BlobOwnerKey, but not the // provider's own key, so it stages nothing and commits nothing. [Fact] public async Task BackgroundChild_WithoutTheProviderKey_StagesAndCommitsNothing() { - // Arrange (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession _) = await NewConversation(); using SequencedChatClient model = new("child words"); BackgroundChildAgent child = new(new ChatClientAgent(model, new ChatClientAgentOptions { ChatHistoryProvider = provider })); @@ -159,12 +150,10 @@ public async Task BackgroundChild_WithoutTheProviderKey_StagesAndCommitsNothing( _ = await child.RunAsync("do it", childSession, cancellationToken: Ct); }); - // Act _ = await outer.RunAsync("go", options: parent.RunOptions(), cancellationToken: Ct); TurnWrite? committed = provider.CommitTurn(childSession!, new TurnCommit(new ChatMessage(ChatRole.User, "do it"))); await provider.DrainAsync(childSession!); - // Assert Assert.NotNull(TurnRegistry.For(childSession)); Assert.True(childSession!.StateBag.TryGetValue(BlobOwnerKey.Value, out string? _)); Assert.False(childSession.StateBag.TryGetValue(StateKey, out string? _)); @@ -173,11 +162,10 @@ public async Task BackgroundChild_WithoutTheProviderKey_StagesAndCommitsNothing( Assert.Empty(store.Rows); } - // Design section 3, "Cut mid-text": the user, the finished tool pairs, and exactly the shown text. + // A cut mid-text keeps the user, the finished tool pairs, and exactly the shown text. [Fact] public async Task CommitTurn_CutMidText_KeepsTheFinishedPairAndTheShownText() { - // Arrange (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); provider.BeginTurn(session, 0); AgentResponse seen = new( @@ -188,32 +176,27 @@ public async Task CommitTurn_CutMidText_KeepsTheFinishedPairAndTheShownText() new ChatMessage(ChatRole.Assistant, "Hello there"), ]); - // Act _ = provider.CommitTurn( session, new TurnCommit(new ChatMessage(ChatRole.User, "hi")) { Seen = seen, Cut = new TurnCut("Hel", TimeSpan.FromMilliseconds(300)) }); await provider.DrainAsync(session); - // Assert Assert.Equal(["user:text", "assistant:call", "tool:result", "assistant:text"], Kinds(store.Rows.Select(row => row.Content))); Assert.Equal("Hel", store.Rows[^1].Content.Text); } - // Design section 3, Cut table: a sealed turn is rewritten, never appended to; an older turn answers false. + // A sealed turn is rewritten, never appended to; an older turn answers false. [Fact] public async Task RewriteReply_OnlyTheLastTurnIsRewritten_AndNothingIsAppended() { - // Arrange (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); AppendTurn(provider, session, turnIndex: 0, "one", "first"); AppendTurn(provider, session, turnIndex: 1, "two", "second reply"); - // Act bool older = provider.RewriteReply(session, 0, "fir"); bool last = provider.RewriteReply(session, 1, "second"); await provider.DrainAsync(session); - // Assert Assert.False(older); Assert.True(last); Assert.Equal(2, store.Appends); @@ -224,16 +207,13 @@ public async Task RewriteReply_OnlyTheLastTurnIsRewritten_AndNothingIsAppended() [Fact] public async Task CommitTurn_CutBeforeAnyWord_NamesTheUserMessageAndNoReply() { - // Arrange (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); provider.BeginTurn(session, 0); - // Act (string UserMessageId, string? ReplyMessageId)? ids = provider.CommitTurn( session, new TurnCommit(new ChatMessage(ChatRole.User, "hi")) { Cut = new TurnCut(string.Empty, null) })?.Ids; await provider.DrainAsync(session); - // Assert ConversationMessage only = Assert.Single(store.Rows); Assert.Equal((only.MessageId, (string?)null), ids); } diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs index 357977a4..1de8b309 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs @@ -7,6 +7,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Transcript { @@ -16,7 +17,7 @@ internal static class AgentCoreChatHistoryProviderTestSupport { internal const string ConversationId = "conversation-1"; - /// The key that binds a session to its conversation (design section 2, "StateKeys"). + /// The key that binds a session to its conversation. internal const string StateKey = "agentcore.history"; /// diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs index 8ed60664..f986da25 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs @@ -16,15 +16,12 @@ public sealed class AgentCoreChatHistoryProviderTests [Fact] public async Task ProvideChatHistory_AfterAppend_ReturnsMessagesInOrder() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore _, StubSession? session) = await NewConversation(); AppendTurn(provider, session, turnIndex: 0, "hello", "hi there"); AppendTurn(provider, session, turnIndex: 1, "order 41?", "it ships Friday"); - // Act IReadOnlyList history = await ProvideAsync(provider, session); - // Assert Assert.Equal( ["hello", "hi there", "order 41?", "it ships Friday"], history.Select(message => message.Text)); @@ -33,14 +30,11 @@ public async Task ProvideChatHistory_AfterAppend_ReturnsMessagesInOrder() [Fact] public async Task AppendTurn_MultipleTurns_OrdinalsAreDenseAndUnique() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore? store, StubSession? session) = await NewConversation(); AppendTurn(provider, session, turnIndex: 0, "hello", "hi there"); - // Act AppendTurn(provider, session, turnIndex: 1, "order 41?", "it ships Friday"); - // Assert await provider.DrainAsync(session); Assert.Equal([0, 1, 2, 3], store.Rows.Select(row => row.Ordinal)); Assert.Equal([0, 0, 1, 1], store.Rows.Select(row => row.TurnIndex)); @@ -50,13 +44,10 @@ public async Task AppendTurn_MultipleTurns_OrdinalsAreDenseAndUnique() [Fact] public async Task AppendTurn_RefusedTurn_IsStored() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore? store, StubSession? session) = await NewConversation(); - // Act AppendTurn(provider, session, turnIndex: 0, "something flagged", "I can't help with that."); - // Assert await provider.DrainAsync(session); Assert.Equal( ["something flagged", "I can't help with that."], @@ -66,28 +57,24 @@ public async Task AppendTurn_RefusedTurn_IsStored() [Fact] public async Task AppendTurn_FailedTurn_IsStored() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore? store, StubSession? session) = await NewConversation(); - // Act AppendTurn(provider, session, turnIndex: 0, "check my order", "Sorry, I had trouble with that."); - // Assert await provider.DrainAsync(session); Assert.Equal( ["check my order", "Sorry, I had trouble with that."], store.Rows.Select(row => row.Content.Text)); } - // Design section 2: the hook stages and writes nothing durable; section 7 item 1: a read never returns staged messages. + // The hook stages and writes nothing durable. A later run of the same turn reads what the + // finished run was told and answered after the conversation. [Fact] public async Task StoreChatHistory_FinishedRun_StagesTheResponseAndStoresNothing() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore? store, StubSession? session) = await NewConversation(); provider.BeginTurn(session, turnIndex: 0); - // Act #pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. await provider.InvokedAsync( new ChatHistoryProvider.InvokedContext( @@ -98,17 +85,17 @@ [new ChatMessage(ChatRole.Assistant, "it ships Friday")]), TestContext.Current.CancellationToken); #pragma warning restore MAAI001 - // Assert await provider.DrainAsync(session); Assert.Empty(store.Rows); - Assert.Empty(await ProvideAsync(provider, session)); + Assert.Equal( + ["ask for the id\norder 41?", "it ships Friday"], + (await ProvideAsync(provider, session)).Select(message => message.Text)); Assert.Equal(["it ships Friday"], provider.Staged(session).Select(message => message.Text)); } [Fact] public async Task AppendTurn_ConcurrentAppends_LosesNoMessage() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore? store, StubSession? session) = await NewConversation(); provider.BeginTurn(session, turnIndex: 0); TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -126,11 +113,9 @@ public async Task AppendTurn_ConcurrentAppends_LosesNoMessage() }, TestContext.Current.CancellationToken))]; - // Act start.SetResult(); await Task.WhenAll(turns); - // Assert await provider.DrainAsync(session); Assert.Equal(40, store.Rows.Count); Assert.Equal(Enumerable.Range(0, 40), store.Rows.Select(row => row.Ordinal).Order()); @@ -139,7 +124,6 @@ public async Task AppendTurn_ConcurrentAppends_LosesNoMessage() [Fact] public async Task ProvideChatHistory_TwoConcurrentSessions_DoNotMix() { - // Arrange AgentCoreChatHistoryProvider provider = new(new RecordingConversationStore()); StubSession first = new(); StubSession second = new(); @@ -147,36 +131,29 @@ public async Task ProvideChatHistory_TwoConcurrentSessions_DoNotMix() _ = provider.BeginConversation(second, "conversation-b", []); AppendTurn(provider, first, turnIndex: 0, "a said", "a heard"); - // Act AppendTurn(provider, second, turnIndex: 0, "b said", "b heard"); - // Assert Assert.Equal(["a said", "a heard"], (await ProvideAsync(provider, first)).Select(message => message.Text)); Assert.Equal(["b said", "b heard"], (await ProvideAsync(provider, second)).Select(message => message.Text)); } - // Design section 2: StateKeys = ["agentcore.history"]. + // StateKeys is ["agentcore.history"]. [Fact] public void StateKeys_IsTheConversationKey() { - // Arrange AgentCoreChatHistoryProvider provider = new(); - // Assert Assert.Equal([StateKey], provider.StateKeys); } - // Design section 2: BeginConversation files the conversation id under the provider's key, and nothing else enters the bag. + // BeginConversation files the conversation id under the provider's key, and nothing else enters the bag. [Fact] public async Task AppendTurn_LeavesOnlyTheConversationKeyInTheStateBag() { - // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore _, StubSession? session) = await NewConversation(); - // Act AppendTurn(provider, session, turnIndex: 0, "order 41?", "it ships Friday"); - // Assert Assert.Equal(1, session.StateBag.Count); Assert.True(session.StateBag.TryGetValue(StateKey, out string? conversationId)); Assert.Equal(ConversationId, conversationId); @@ -190,18 +167,15 @@ public async Task AppendTurn_LeavesOnlyTheConversationKeyInTheStateBag() [Fact] public void AppendTurn_TwoSessions_EachHoldsItsOwnTranscript() { - // Arrange AgentCoreChatHistoryProvider provider = new(new RecordingConversationStore()); StubSession first = new(); StubSession second = new(); _ = provider.BeginConversation(first, "conversation-a", []); _ = provider.BeginConversation(second, "conversation-b", []); - // Act AppendTurn(provider, first, turnIndex: 0, "a said", "a heard"); AppendTurn(provider, second, turnIndex: 0, "b said", "b heard"); - // Assert Assert.Equal(["a said", "a heard"], provider.Read(first).Select(message => message.Text)); Assert.Equal(["b said", "b heard"], provider.Read(second).Select(message => message.Text)); } @@ -209,15 +183,12 @@ public void AppendTurn_TwoSessions_EachHoldsItsOwnTranscript() [Fact] public async Task AppendTurn_BackingStoreThrows_DoesNotFailTheTurn() { - // Arrange AgentCoreChatHistoryProvider provider = new(new ThrowingConversationStore()); StubSession session = new(); _ = provider.BeginConversation(session, ConversationId, []); - // Act AppendTurn(provider, session, turnIndex: 0, "hello", "hi there"); - // Assert await provider.DrainAsync(session); Assert.Equal(["hello", "hi there"], (await ProvideAsync(provider, session)).Select(message => message.Text)); } @@ -225,16 +196,13 @@ public async Task AppendTurn_BackingStoreThrows_DoesNotFailTheTurn() [Fact] public async Task BeginConversation_BackingStoreThrows_TellsTheReporterWhichTurnWasLost() { - // Arrange AgentCoreChatHistoryProvider provider = new(new ThrowingConversationStore()); StubSession session = new(); DroppedTurns dropped = new(); _ = provider.BeginConversation(session, ConversationId, [], dropped); - // Act AppendTurn(provider, session, turnIndex: 3, "hello", "hi there"); - // Assert await provider.DrainAsync(session); Assert.Equal([3], dropped.Turns); } diff --git a/tests/AgentCore.Application.Tests/Transcript/CompiledHistoryProviderTests.cs b/tests/AgentCore.Application.Tests/Transcript/CompiledHistoryProviderTests.cs index 753182c4..257be43b 100644 --- a/tests/AgentCore.Application.Tests/Transcript/CompiledHistoryProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/CompiledHistoryProviderTests.cs @@ -3,32 +3,18 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { /// - /// Where store 1 is bound: on the compiled agent of rows 1 and 2, and on nothing else. + /// Where the message store is bound: on the compiled agent of rows 1 and 2, and on nothing else. /// - /// - /// - /// The provider is bound once, at compile time, so no run has to name a conversation to keep it — - /// which is what lets ThrowOnChatHistoryProviderConflict stay at the framework default. - /// - /// - /// Rows 3 and 4 must stay unbound. A node is handed the conversation in the request messages the turn loop - /// renders, and it runs on a session the workflow made rather than the conversation's, so store 1 on a node - /// is a second history source keyed on a session it does not know. - /// - /// - /// Every test here runs offline: no network conversation and no API key. - /// - /// public sealed class CompiledHistoryProviderTests { private const string SingleAgentYaml = @@ -107,9 +93,6 @@ public sealed class CompiledHistoryProviderTests /// What the delegating agent tells the caller once the sub-agent has answered. private const string FrontReply = "your order is on its way"; - // --------------------------------------------------------------------------------------------- - // Hazard 3 and 4: which agents hold the provider, and that every row still constructs. - // --------------------------------------------------------------------------------------------- [Theory] [InlineData(SingleAgentYaml)] [InlineData(PolicyYaml)] @@ -135,7 +118,7 @@ public void AGraphRow_BindsStoreOneToNoNodeAgent(string yaml) CompiledAgent compiled = Compile(yaml, client); // A ChatClientAgent that is handed no provider builds the framework's own in-memory one, which - // opens empty on each node session and never sees store 1. The fact is that store 1 is not + // opens empty on each node session and never sees the message store. The fact is that message store is not // there, not that nothing is. Assert.NotEmpty(compiled.Agents); Assert.All( @@ -143,14 +126,11 @@ public void AGraphRow_BindsStoreOneToNoNodeAgent(string yaml) agent => Assert.IsNotType(ChatClientAgentOf(agent).ChatHistoryProvider)); } - // --------------------------------------------------------------------------------------------- - // The safety check the workaround switched off. - // --------------------------------------------------------------------------------------------- [Fact] public async Task AModelThatKeepsTheHistoryItself_ConflictsWithStoreOne() { // A response that carries a conversation id is how a service says it keeps the history itself. - // That and store 1 are two answers to one question, and the framework refuses both at once. + // That and the message store are two answers to one question, and the framework refuses both at once. // Switching this check off to buy something else — a telemetry attribute, say — leaves a conversation // whose model silently sees one message and no history. ServerSideHistoryChatClient client = new("hello"); @@ -164,9 +144,6 @@ public async Task AModelThatKeepsTheHistoryItself_ConflictsWithStoreOne() Assert.Contains("ChatHistoryProvider", conflict.Message, StringComparison.Ordinal); } - // --------------------------------------------------------------------------------------------- - // Hazard 1: a workflow node reads the call once. - // --------------------------------------------------------------------------------------------- [Theory] [InlineData(PatternGraphYaml)] [InlineData(ExplicitGraphYaml)] @@ -186,9 +163,6 @@ public async Task AGraphRow_ReplaysTheConversationToItsNodesExactlyOnce(string y Assert.Equal(1, Mentions(responder.Requests[1], FirstUtterance)); } - // --------------------------------------------------------------------------------------------- - // Hazard 2: a delegated agent starts empty. - // --------------------------------------------------------------------------------------------- [Fact] public async Task ADelegatedAgent_NeverReadsTheCallersTranscript() { @@ -199,7 +173,7 @@ public async Task ADelegatedAgent_NeverReadsTheCallersTranscript() _ = await session.RunTurnAsync(FirstUtterance, TestContext.Current.CancellationToken); _ = await session.RunTurnAsync(SecondUtterance, TestContext.Current.CancellationToken); - // Agent-as-tool is call and return: the inner run gets a fresh AgentSession, and store 1 keys + // Agent-as-tool is call and return: the inner run gets a fresh AgentSession, and the message store keys // on the session, so the second delegation opens on an empty history exactly like the first. Assert.Equal(2, specialist.Requests.Count); Assert.All( @@ -347,13 +321,6 @@ private void Record(IEnumerable messages) /// /// Calls the one tool it is offered on every turn, then answers. /// - /// - /// delegates once per call, because it stops as soon as the - /// request carries any tool result. Store 1 of rows 1 and 2 keeps the finished tool pair, so on a - /// second turn that client would never delegate again — and a fact about the SECOND delegation - /// needs one that does. The rule here reads the last message instead: a request that ends on the - /// caller delegates, and a request that ends on the tool result answers. - /// private sealed class DelegatingChatClient(string reply) : IChatClient { private const string ConversationId = "conversation_1"; diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs index ab231481..96076ece 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs @@ -1,12 +1,12 @@ using System.Text.Json.Nodes; using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; using AgentCore.Domain; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionResumeTestSupport; namespace AgentCore.Application.Tests.Transcript @@ -48,12 +48,10 @@ public sealed class ConversationSessionCatchUpTests - { id: closed, agent: done, terminal: true } """; - // IConversationStore.AppendAsync refuses a turn index the conversation already saved (owner ruling 2026-09-24, - // "refuse at save"). Session B saved turn 1, so session A, which ran turn 0, must run its next turn as turn 2. + // IConversationStore.AppendAsync refuses a turn index the conversation already saved. Session B saved turn 1, so session A, which ran turn 0, must run its next turn as turn 2. [Fact] public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_SavesItsNextTurnUnderTheNextIndex() { - // Arrange InMemoryConversationStore store = new(); using ScriptedChatClient replyA = new("a"); ConversationSession a = CreateSession(OneAgentYaml, replyA, store); @@ -65,11 +63,9 @@ public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_SavesItsNextTurnUnde _ = await b.RunTurnAsync("q1", Ct); await b.FlushTranscriptAsync(); - // Act TurnResult third = await a.RunTurnAsync("q2", Ct); await a.FlushTranscriptAsync(); - // Assert Assert.Equal(2, third.TurnIndex); IReadOnlyList rows = await store.ReadForSessionAsync(a.ConversationId, Ct); Assert.Equal( @@ -80,7 +76,6 @@ public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_SavesItsNextTurnUnde [Fact] public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RunsInTheStageAndSeesTheSlotsThatTurnStored() { - // Arrange InMemoryConversationStore store = new(); using RequestRecordingChatClient reply = new("a0", "a2"); ConversationSession a = CreateSession(OrderYaml, reply, store); @@ -93,11 +88,9 @@ public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RunsInTheStageAndSee Slots = new Dictionary { ["orderStatus"] = "shipped", ["turnsInAlpha"] = 2L }, }); - // Act TurnResult third = await a.RunTurnAsync("q2", Ct); await a.FlushTranscriptAsync(); - // Assert Assert.Equal((2, "beta"), (third.TurnIndex, third.StageBefore)); Assert.EndsWith("STAGE-BETA", reply.Instructions[^1], StringComparison.Ordinal); ConversationSessionState? stored = (await store.GetAsync(a.ConversationId, Ct))?.State; @@ -112,7 +105,6 @@ public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RunsInTheStageAndSee [Fact] public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_HoldsNoSlotThatTurnDidNotStore() { - // Arrange InMemoryConversationStore store = new(); using ScriptedChatClient reply = new("a"); ConversationSession a = CreateSession(OrderYaml, reply, store); @@ -120,17 +112,14 @@ public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_HoldsNoSlotThatTurnD await a.FlushTranscriptAsync(); await SaveAnotherSessionsTurnAsync(store, a.ConversationId, new ConversationSessionState { NextTurnIndex = 2, Stage = "alpha" }); - // Act _ = await a.RunTurnAsync("q2", Ct); - // Assert Assert.Equal(1, a.State.Read("turnsInAlpha")?.GetValue()); } [Fact] public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RefusesTheTurnWhenThatTurnEndedTheConversation() { - // Arrange InMemoryConversationStore store = new(); using ScriptedChatClient reply = new("a"); ConversationSession a = CreateSession(OrderYaml, reply, store); @@ -139,10 +128,8 @@ public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RefusesTheTurnWhenTh await SaveAnotherSessionsTurnAsync( store, a.ConversationId, new ConversationSessionState { NextTurnIndex = 2, Stage = "closed", IsComplete = true }); - // Act Exception? refused = await Record.ExceptionAsync(() => a.RunTurnAsync("q2", Ct)); - // Assert Assert.IsType(refused); IReadOnlyList rows = await store.ReadForSessionAsync(a.ConversationId, Ct); Assert.Equal(4, rows.Count); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionEditTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionEditTests.cs index df15ff38..593a59c1 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionEditTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionEditTests.cs @@ -1,11 +1,12 @@ using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; -using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; namespace AgentCore.Application.Tests.Transcript @@ -27,14 +28,14 @@ public async Task ATurn_EditingAMessageUnderTheSummary_CutsTheStoreFirst_AndRead await SeedPlainTurnAsync(store, turnIndex: 2, "q2", "a2"); CapturingChatClient reply = new(_ => Task.CompletedTask, "a3", "a0, rewritten reply"); - RecordingObserver observer = new(); + RecordingHook hook = new(); ConversationSession session = CreateSession( - reply, store, conversationId: "c1", compaction: Summary(new ScriptedChatClient("the gist of it"), minimumPreservedGroups: 0), observer: observer); + reply, store, conversationId: "c1", compaction: Summary(new ScriptedChatClient("the gist of it"), minimumPreservedGroups: 0), hooks: [hook]); _ = await session.RunTurnAsync("q3", TestContext.Current.CancellationToken); await session.FlushTranscriptAsync(); - // Turns 0 and 1 (q0/a0, q1/a1) are under the summary (D11: turn 2 stayed live as the newest + // Turns 0 and 1 (q0/a0, q1/a1) are under the summary (turn 2 stays live as the newest // existing turn). "m-0-a" — turn 0's reply — sits inside that span. _ = Assert.Single(await store.ReadForSessionAsync("c1", TestContext.Current.CancellationToken), row => row.CoversUpTo == 3); store.RowsRead.Clear(); @@ -58,9 +59,9 @@ public async Task ATurn_EditingAMessageUnderTheSummary_CutsTheStoreFirst_AndRead // Turns 1 through 3 (q1/a1, q2/a2, q3/a3) went, and the trail says so even though the session // never held turn 1's rows. - ConversationEvent superseded = Assert.Single(observer.Events, raised => raised.Kind == ConversationEventKind.TurnSuperseded); - Assert.Equal("1", superseded.Payload![AuditPayloadKeys.WithdrewFromTurnIndex]); - Assert.Equal("3", superseded.Payload[AuditPayloadKeys.WithdrewThroughTurnIndex]); + await session.FlushNoticesAsync(); + TurnSuperseded superseded = Assert.Single(hook.Of()); + Assert.Equal((1, 3), (superseded.WithdrewFrom, superseded.WithdrewThrough)); } [Fact] @@ -73,9 +74,9 @@ public async Task ATurn_EditingAMessageTheStoreDoesNotHold_CutsNothing_AndKeepsT await SeedPlainTurnAsync(store, turnIndex: 2, "q2", "a2"); CapturingChatClient reply = new(_ => Task.CompletedTask, "a3", "a4"); - RecordingObserver observer = new(); + RecordingHook hook = new(); ConversationSession session = CreateSession( - reply, store, conversationId: "c1", compaction: Summary(new ScriptedChatClient("the gist of it"), minimumPreservedGroups: 0), observer: observer); + reply, store, conversationId: "c1", compaction: Summary(new ScriptedChatClient("the gist of it"), minimumPreservedGroups: 0), hooks: [hook]); _ = await session.RunTurnAsync("q3", TestContext.Current.CancellationToken); await session.FlushTranscriptAsync(); @@ -86,12 +87,13 @@ public async Task ATurn_EditingAMessageTheStoreDoesNotHold_CutsNothing_AndKeepsT TestContext.Current.CancellationToken); await session.FlushTranscriptAsync(); - // Nothing went: the summary stands, and the turn compacts again over it (D11: q3/a3 was the + // Nothing went: the summary stands, and the turn compacts again over it (q3/a3 was the // newest existing turn, so it stays live and the summary now covers everything before it). Assert.Equal(["assistant:[Summary]\nthe gist of it", "user:q3", "assistant:a3", "user:q4"], reply.Requests[1]); _ = Assert.Single(await store.ReadForSessionAsync("c1", TestContext.Current.CancellationToken), row => row.CoversUpTo == 5); Assert.Equal(10, (await store.ReadAllAsync("c1", TestContext.Current.CancellationToken)).Count); - Assert.DoesNotContain(observer.Events, raised => raised.Kind == ConversationEventKind.TurnSuperseded); + await session.FlushNoticesAsync(); + Assert.Empty(hook.Of()); } [Fact] diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs index 5f902412..87e6c302 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs @@ -1,7 +1,7 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; using AgentCore.Domain; @@ -10,6 +10,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; namespace AgentCore.Application.Tests.Transcript @@ -74,19 +75,19 @@ public async Task CompactAsync_TheRowWriteFails_IsReportedAsADroppedWriteAndTheT await SeedPlainTurnAsync(store, turnIndex: 1, "q1", "a1"); await SeedPlainTurnAsync(store, turnIndex: 2, "q2", "a2"); - RecordingObserver observer = new(); + RecordingHook hook = new(); ConversationSession session = CreateSession( new ScriptedChatClient("a3"), store, conversationId: "c1", compaction: Summary(new ScriptedChatClient("the gist of it"), minimumPreservedGroups: 0), - observer: observer); + hooks: [hook]); TurnResult result = await session.RunTurnAsync("q3", TestContext.Current.CancellationToken); await session.FlushTranscriptAsync(); Assert.Equal("a3", result.ReplyText); - ConversationEvent dropped = Assert.Single(observer.Events, conversationEvent => conversationEvent.Kind == ConversationEventKind.TranscriptWriteFailed); - Assert.Null(dropped.EventId); - Assert.Contains("summary row refused", dropped.Payload![ConversationEventPayloadKeys.Reason], StringComparison.Ordinal); + await session.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.TranscriptWriteFailed); + Assert.Contains("summary row refused", dropped.Message, StringComparison.Ordinal); // The words landed; only the summary did not. Assert.DoesNotContain(await store.ReadForSessionAsync("c1", TestContext.Current.CancellationToken), row => row.CoversUpTo is not null); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTestSupport.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTestSupport.cs index 47f7931b..6e3a8058 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTestSupport.cs @@ -4,8 +4,8 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; @@ -14,6 +14,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { @@ -25,9 +26,9 @@ internal static class ConversationSessionCompactionTestSupport /// The stages the agent runs in place of the window-derived ones: sized to fire on a handful of /// fixture messages. /// - /// The reply model's window, in tokens. The fixed stages fire at 75% of it (D7). + /// The reply model's window, in tokens. The fixed stages fire at 75% of it. internal static ConversationSession CreateSession( - IChatClient reply, IConversationStore store, string? conversationId = null, ILogger? logger = null, CompactionStages? compaction = null, int contextWindow = 128_000, IConversationObserver? observer = null) + IChatClient reply, IConversationStore store, string? conversationId = null, ILogger? logger = null, CompactionStages? compaction = null, int contextWindow = 128_000, IReadOnlyList? hooks = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ConversationSessionResumeTestSupport.OneAgentYaml); FakeChatClientFactory chatClients = new(reply, contextWindow); @@ -38,14 +39,14 @@ internal static ConversationSession CreateSession( ConversationStore = store, Tools = TestToolRegistry.From(document, null, TestContext.Current.CancellationToken), Compaction = compaction, + Hooks = hooks, })["main"]; ConversationSessionFactory factory = new( compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null, - logger: logger, - observers: observer is null ? [] : [observer]); + logger: logger); return factory.Create(conversationId); } diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTests.cs index 9c0bfdff..a436ead8 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionTests.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; @@ -7,6 +6,7 @@ using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; namespace AgentCore.Application.Tests.Transcript @@ -37,7 +37,7 @@ public async Task TheFiringTurn_CallsTheSummariserOnce_AndSendsTheSummaryInItsOw _ = Assert.Single(summariser.Requests); - // Turn 2 (q2/a2) is the newest existing turn and stays live (D11); turns 0 and 1 go under the summary. + // Turn 2 (q2/a2) is the newest existing turn and stays live; turns 0 and 1 go under the summary. Assert.Equal(["assistant:[Summary]\nthe gist of it", "user:q2", "assistant:a2", "user:q3"], Assert.Single(reply.Requests)); IReadOnlyList forSession = await store.ReadForSessionAsync("c1", TestContext.Current.CancellationToken); @@ -135,7 +135,7 @@ public async Task ACapOnlyStage_WritesNoRow_AndTheRequestCarriesTheCappedResults [Fact] public async Task ATurn_WhoseViewCrossesThreeQuartersOfTheWindow_FiresTheRealPipeline() { - // A 400-token window fires at 300 and stops at 200 (D7). One short turn stays well under; + // A 400-token window fires at 300 and stops at 200. One short turn stays well under; // twelve turns of ~80 characters each land well over. const int Window = 400; InMemoryConversationStore store = new(); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionConversationRowTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionConversationRowTests.cs index 66112797..1d37e259 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionConversationRowTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionConversationRowTests.cs @@ -4,12 +4,12 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { @@ -26,28 +26,25 @@ public sealed class ConversationSessionConversationRowTests agent: only """; - /// A store 0 that is down: it takes no row, so no word may follow. + /// A conversation store that is down: it takes no row, so no word may follow. private sealed class RefusingCreate(IConversationStore inner) : DelegatingConversationStore(inner) { public override ValueTask CreateAsync( string conversationId, CancellationToken cancellationToken = default) { - throw new InvalidOperationException("store 0 is down."); + throw new InvalidOperationException("the conversation store is down."); } } [Fact] public async Task ATurn_CreatesTheConversationRow_BeforeItWritesAnyWord() { - // Arrange InMemoryConversationStore store = new(); using ScriptedChatClient reply = new("hello"); ConversationSession session = CreateSession(OneAgentYaml, reply, store); - // Act _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - // Assert await session.FlushTranscriptAsync(); Assert.NotNull(await store.GetAsync(session.ConversationId, TestContext.Current.CancellationToken)); Assert.NotEmpty(await store.ReadAllAsync(session.ConversationId, TestContext.Current.CancellationToken)); @@ -56,18 +53,15 @@ public async Task ATurn_CreatesTheConversationRow_BeforeItWritesAnyWord() [Fact] public async Task ATurn_WhenStoreZeroRefusesTheRow_FailsAndWritesNoWords() { - // Arrange InMemoryConversationStore inner = new(); RefusingCreate store = new(inner); using ScriptedChatClient reply = new("hello"); ConversationSession session = CreateSession(OneAgentYaml, reply, store); - // Act InvalidOperationException fault = await Assert.ThrowsAsync( () => session.RunTurnAsync("hi", TestContext.Current.CancellationToken)); - // Assert - Assert.Equal("store 0 is down.", fault.Message); + Assert.Equal("the conversation store is down.", fault.Message); Assert.Empty(await inner.ReadAllAsync(session.ConversationId, TestContext.Current.CancellationToken)); } diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs index 3f65fef0..15f70b3a 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs @@ -4,23 +4,20 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Domain; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { /// /// Pins what a graph row keeps and what it sends: the caller-facing turn, and nothing else. /// - /// - /// A workflow takes no chat history provider and no session of ours, so the turn loop carries the - /// conversation itself. Every test here runs offline, with no network conversation and no API key. - /// public sealed class ConversationSessionGraphTranscriptTests { /// What the first node says while it works. The caller never hears it. @@ -44,22 +41,19 @@ public sealed class ConversationSessionGraphTranscriptTests """; /// - /// Store 1 holds what the caller said and what the caller heard. The graph's node-to-node + /// The message store holds what the caller said and what the caller heard. The graph's node-to-node /// chatter is neither, so it never enters the record. /// [Fact] public async Task Run_GraphRow_StoresOnlyCallerFacingMessages() { - // Arrange RecordingConversationStore store = new(); RecordingNodeClient researcher = new(Thinking); RecordingNodeClient responder = new(Spoken); ConversationSession session = CreateSession(researcher, responder, store); - // Act _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - // Assert await session.FlushTranscriptAsync(); Assert.Equal( ["where is my order", Spoken], @@ -73,16 +67,14 @@ public async Task Run_GraphRow_StoresOnlyCallerFacingMessages() [Fact] public async Task Run_GraphRow_SecondTurn_ModelSeesNoIntermediateNodeReply() { - // Arrange RecordingNodeClient researcher = new(Thinking); RecordingNodeClient responder = new(Spoken); ConversationSession session = CreateSession(researcher, responder, new RecordingConversationStore()); _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - // Act _ = await session.RunTurnAsync("and the second one?", TestContext.Current.CancellationToken); - // Assert. The demotion of an assistant message to user is why the conversation rides one system + // The demotion of an assistant message to user is why the conversation rides one system // message: the node reads who said what off the line, not off the role. List second = researcher.Requests[1]; Assert.Contains( @@ -103,28 +95,24 @@ public async Task Run_GraphRow_SecondTurn_ModelSeesNoIntermediateNodeReply() [Fact] public async Task Run_GraphRow_ReplyIsFinalNodeOnly() { - // Arrange RecordingConversationStore store = new(); RecordingNodeClient researcher = new(Thinking); RecordingNodeClient responder = new(Spoken); ConversationSession session = CreateSession(researcher, responder, store); - // Act TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - // Assert Assert.Equal(Spoken, turn.ReplyText); Assert.DoesNotContain(Thinking, turn.ReplyText, StringComparison.Ordinal); } /// - /// R4 on a graph row: the record holds the words the caller heard, and never the tail the + /// On a graph row, the record holds the words the caller heard, and never the tail the /// answering node went on to produce. /// [Fact] public async Task Interrupt_MidGraphReply_StoresTheHeardWordsOnly() { - // Arrange RecordingConversationStore store = new(); RecordingNodeClient researcher = new(Thinking); using ScriptedChatClient responder = new("Order 41 ", "ships Friday.") { GateAfterFirstFragment = true }; @@ -132,10 +120,8 @@ public async Task Interrupt_MidGraphReply_StoresTheHeardWordsOnly() (Task? turn, Task? spoke) = StartGatedTurn(session, "where is my order"); await spoke; - // Act bool recorded = session.Cut(0, new TurnCut("Order 41", TimeSpan.FromMilliseconds(300))); - // Assert Assert.True(recorded); responder.OpenGate(); await turn; @@ -152,7 +138,6 @@ public async Task Interrupt_MidGraphReply_StoresTheHeardWordsOnly() [Fact] public async Task Interrupt_BeforeAnyGraphWordLanded_StoresTheCallerUtteranceAlone() { - // Arrange RecordingConversationStore store = new(); RecordingNodeClient researcher = new(Thinking); using ScriptedChatClient responder = new("Order 41 ", "ships Friday.") { GateAfterFirstFragment = true }; @@ -160,10 +145,8 @@ public async Task Interrupt_BeforeAnyGraphWordLanded_StoresTheCallerUtteranceAlo (Task? turn, Task? spoke) = StartGatedTurn(session, "where is my order"); await spoke; - // Act bool recorded = session.Cut(0, new TurnCut(string.Empty, TimeSpan.FromMilliseconds(40))); - // Assert Assert.True(recorded); responder.OpenGate(); await turn; @@ -175,11 +158,6 @@ public async Task Interrupt_BeforeAnyGraphWordLanded_StoresTheCallerUtteranceAlo /// The conversation to run the turn on. /// What the caller said. /// The running turn, and a task that completes at its first spoken update. - /// - /// A run that has handed the host nothing is not the turn the caller is hearing, so a barge-in - /// before the first update takes the amendment path instead and records nothing against this - /// turn. Waiting for that update is what makes the cut land in the reply. - /// private static (Task Turn, Task Spoke) StartGatedTurn(ConversationSession session, string userInput) { TaskCompletionSource spoke = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionOutsideAppendTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionOutsideAppendTests.cs index 63a3e7aa..d087f84a 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionOutsideAppendTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionOutsideAppendTests.cs @@ -1,20 +1,21 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; using AgentCore.Application.Transcript; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn; using static AgentCore.Application.Tests.Transcript.ConversationSessionResumeTestSupport; namespace AgentCore.Application.Tests.Transcript { /// /// A turn sees a row a host appended from outside any turn — after a human handoff ends, say, - /// possibly from another machine — and its own new rows still continue from store 0's counter. + /// possibly from another machine — and its own new rows still continue from the conversation store's counter. /// #pragma warning disable CA1859 // store is typed as IConversationStore throughout: AppendMessageAsync(conversationId, message, ct) // is a default interface method, and only resolves through the interface type. @@ -163,7 +164,7 @@ public async Task SecondTurn_WithNoOutsideAppend_DoesNotReadTheWordsBack() _ = await session.RunTurnAsync("are you still there?", TestContext.Current.CancellationToken); await session.FlushTranscriptAsync(); - // The counter store 0 hands back matches the one the session holds, so nobody wrote from + // The counter the conversation store hands back matches the one the session holds, so nobody wrote from // outside and there is nothing to read. Assert.Equal(0, store.Reads); Assert.Equal( diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs index 2796a4ba..359e70b1 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs @@ -4,14 +4,15 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { @@ -23,15 +24,12 @@ public sealed class ConversationSessionRefusedCatchUpTests { private const string ConversationId = "c-refused"; - /// The event id of Log.TurnRunFaulted. - private const int TurnRunFaultedEvent = 28; - private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact(Timeout = 30_000)] public async Task AfterARefusedTurn_TheNextTurnRunsOnTheStoredWordsAndState() { - // Arrange: the busy mark fails open, so both sessions run turn 0 at once, and B saves it first. + // The busy mark fails open, so both sessions run turn 0 at once, and B saves it first. HeldFirstTurnChatClient reply = new("I am Alice"); InMemoryConversationStore inner = new(); ConversationSessionFactory factory = Build(reply, new UnmarkableStore(inner)); @@ -44,11 +42,9 @@ public async Task AfterARefusedTurn_TheNextTurnRunsOnTheStoredWordsAndState() reply.Release(); Exception? refused = await Record.ExceptionAsync(() => alice); - // Act TurnResult next = await a.RunTurnAsync("what is my name", Ct); await a.FlushTranscriptAsync(); - // Assert _ = Assert.IsType(refused); Assert.Equal(1, next.TurnIndex); Assert.Equal( @@ -60,32 +56,8 @@ public async Task AfterARefusedTurn_TheNextTurnRunsOnTheStoredWordsAndState() Assert.Equal(2, stored?.State?.NextTurnIndex); } - [Fact(Timeout = 30_000)] - public async Task ARunFaultOnATurnTheStoreRefused_IsLoggedOnce() - { - // Arrange - HeldFirstTurnChatClient reply = new("I am Alice") { FaultsHeldRequest = true }; - using RecordingLoggerFactory logs = new(); - ConversationSessionFactory factory = Build(reply, new UnmarkableStore(new InMemoryConversationStore()), logs.CreateLogger("session")); - ConversationSession a = factory.Create(ConversationId); - ConversationSession b = factory.Create(ConversationId); - - Task alice = a.RunTurnAsync("I am Alice", Ct); - await reply.Held.Task.WaitAsync(Ct); - _ = await b.RunTurnAsync("I am Bob", Ct); - - // Act - reply.Release(); - Exception? refused = await Record.ExceptionAsync(() => alice); - - // Assert - _ = Assert.IsType(refused); - CapturedLine line = Assert.Single(logs.Of(TurnRunFaultedEvent)); - Assert.Equal(LogLevel.Error, line.Level); - _ = Assert.IsType(line.Exception); - } - - private static ConversationSessionFactory Build(HeldFirstTurnChatClient reply, IConversationStore store, ILogger? logger = null) + internal static ConversationSessionFactory Build( + HeldFirstTurnChatClient reply, IConversationStore store, ILogger? logger = null, IReadOnlyList? hooks = null) { RoutingChatClientFactory clients = new(reply); _ = clients.Route("fill", new CallerNameFillChatClient()); @@ -98,11 +70,11 @@ private static ConversationSessionFactory Build(HeldFirstTurnChatClient reply, I new GuardEvaluator(compiled.Configuration.Guards), ConversationSessionFactory.CreateExtractor(compiled, clients), logger: logger, - observers: ConversationObservers.Standard(new InMemoryAuditSink(), logger)); + hooks: hooks); } /// A store whose busy table cannot be reached, so the busy mark fails open. - private sealed class UnmarkableStore(IConversationStore inner) : DelegatingConversationStore(inner) + internal sealed class UnmarkableStore(IConversationStore inner) : DelegatingConversationStore(inner) { public override ValueTask TryMarkBusyAsync( string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTestSupport.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTestSupport.cs index fe701d3c..40890aef 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTestSupport.cs @@ -2,12 +2,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { @@ -101,7 +102,7 @@ internal static ConversationSession CreateSession( IChatClient reply, IConversationStore store, string? conversationId = null, - IConversationObserver? observer = null) + AgentHook? hook = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); FakeChatClientFactory chatClients = new(reply); @@ -117,38 +118,9 @@ internal static ConversationSession CreateSession( compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null, - observers: observer is null ? null : [observer]); + hooks: hook is null ? null : [hook]); return factory.Create(conversationId); } } - - /// Keeps every fact of the conversation, in the order the turn loop raised them. - internal sealed class RecordingObserver : IConversationObserver - { - private readonly Lock _gate = new(); - private readonly List _events = []; - - /// Gets what the conversation raised, oldest first. - public IReadOnlyList Events - { - get - { - lock (_gate) - { - return [.. _events]; - } - } - } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - lock (_gate) - { - _events.Add(conversationEvent); - } - - return ValueTask.CompletedTask; - } - } } diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTests.cs index e2a854e0..3b9650a4 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionResumeTests.cs @@ -1,10 +1,10 @@ using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionResumeTestSupport; namespace AgentCore.Application.Tests.Transcript @@ -93,13 +93,13 @@ public async Task ASecondSessionOfOneConversation_WhoseWordsCannotBeRead_Refuses () => resumed.RunTurnAsync("what is my name?", TestContext.Current.CancellationToken)); } - /// A store 1 that takes words and will not give them back. + /// A message store that takes words and will not give them back. private sealed class UnreadableConversationStore(IConversationStore inner) : DelegatingConversationStore(inner) { public override ValueTask> ReadForSessionAsync( string conversationId, CancellationToken cancellationToken = default) { - throw new InvalidOperationException("store 1 will not answer a read."); + throw new InvalidOperationException("the message store will not answer a read."); } } } diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStateRestoreTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStateRestoreTests.cs index 1dfa2dd9..7c42f58a 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStateRestoreTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStateRestoreTests.cs @@ -1,11 +1,13 @@ using System.Text.Json.Nodes; using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; +using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.Application.Tests.Transcript.ConversationSessionResumeTestSupport; namespace AgentCore.Application.Tests.Transcript @@ -34,7 +36,7 @@ public async Task ASecondSessionOfOneConversation_KeepsTheStageTheFirstOneReache // StageBefore, and not Stage. The stage is restored when the session OPENS and the session // opens on the first turn, so the restored stage is the one that turn spoke in, and 'intake' // is what a session that forgot would report. Reading Stage after the turn would prove - // nothing: this document's only guard reads the reserved turnIndex slot, which store 1 + // nothing: this document's only guard reads the reserved turnIndex slot, which the message store // restores off the words on its own, so even a session that remembered nothing would land in // 'help' by the end of the turn. Assert.Equal("help", resumed.LastTurn!.StageBefore); @@ -108,8 +110,8 @@ public async Task ASecondSessionOfOneConversation_DropsASlotTheDocumentNoLongerD TestContext.Current.CancellationToken); using ScriptedChatClient reply = new("sure"); - RecordingObserver observer = new(); - ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-drift", observer); + RecordingHook hook = new(); + ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-drift", hook); _ = await resumed.RunTurnAsync("still there?", TestContext.Current.CancellationToken); Assert.Equal(5L, resumed.State.Read("turnsTaken")!.GetValue()); @@ -117,10 +119,11 @@ public async Task ASecondSessionOfOneConversation_DropsASlotTheDocumentNoLongerD // Dropped, and said so. A document change that quietly costs a conversation one slot is a change // nobody can price afterwards, so the slot that went is named in the fact. - ConversationEvent dropped = Assert.Single(observer.Events, fact => fact.Kind == ConversationEventKind.StateRestorePartial); + await resumed.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.StateRestorePartial); Assert.Equal( "the document no longer declares the slot 'retired'.", - dropped.Payload[ConversationEventPayloadKeys.Reason]); + dropped.Message); } [Fact] @@ -144,8 +147,8 @@ public async Task ASecondSessionOfOneConversation_DropsASlotWhoseDeclaredTypeNoL TestContext.Current.CancellationToken); using ScriptedChatClient reply = new("sure"); - RecordingObserver observer = new(); - ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-retyped", observer); + RecordingHook hook = new(); + ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-retyped", hook); _ = await resumed.RunTurnAsync("still there?", TestContext.Current.CancellationToken); // Back to the declared default, and then this turn's own increment. @@ -153,10 +156,11 @@ public async Task ASecondSessionOfOneConversation_DropsASlotWhoseDeclaredTypeNoL // The other half of what TryWrite answers false for. An operator fixes a retyped slot and a // deleted slot differently, so the two reasons have to read differently. - ConversationEvent dropped = Assert.Single(observer.Events, fact => fact.Kind == ConversationEventKind.StateRestorePartial); + await resumed.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.StateRestorePartial); Assert.Equal( "the slot 'turnsTaken' no longer takes the value it was stored with.", - dropped.Payload[ConversationEventPayloadKeys.Reason]); + dropped.Message); } [Fact] @@ -165,7 +169,7 @@ public async Task ASecondSessionOfOneConversation_DropsAReservedSlotRatherThanRe InMemoryConversationStore store = new(); _ = await store.CreateAsync("C-reserved", TestContext.Current.CancellationToken); - // No writer produces this, but the blob is arbitrary JSON out of store 0 and a host can hand + // No writer produces this, but the blob is arbitrary JSON out of the conversation store and a host can hand // one in directly. StateDocument.TryWrite THROWS on a reserved slot rather than answering // false, so a Restore that just asked it would take the conversation down on its first turn. _ = await store.AppendAsync( @@ -183,8 +187,8 @@ public async Task ASecondSessionOfOneConversation_DropsAReservedSlotRatherThanRe TestContext.Current.CancellationToken); using ScriptedChatClient reply = new("sure"); - RecordingObserver observer = new(); - ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-reserved", observer); + RecordingHook hook = new(); + ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-reserved", hook); _ = await resumed.RunTurnAsync("still there?", TestContext.Current.CancellationToken); @@ -192,10 +196,11 @@ public async Task ASecondSessionOfOneConversation_DropsAReservedSlotRatherThanRe Assert.NotNull(resumed.LastTurn); Assert.Equal(5L, resumed.State.Read("turnsTaken")!.GetValue()); - ConversationEvent dropped = Assert.Single(observer.Events, fact => fact.Kind == ConversationEventKind.StateRestorePartial); + await resumed.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.StateRestorePartial); Assert.Equal( "the slot 'stage' is reserved, and a reserved slot is never restored.", - dropped.Payload[ConversationEventPayloadKeys.Reason]); + dropped.Message); } [Fact] @@ -219,8 +224,8 @@ public async Task ASecondSessionOfOneConversation_IgnoresAStateBlobWrittenInASha TestContext.Current.CancellationToken); using ScriptedChatClient reply = new("sure"); - RecordingObserver observer = new(); - ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-future", observer); + RecordingHook hook = new(); + ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-future", hook); _ = await resumed.RunTurnAsync("still there?", TestContext.Current.CancellationToken); @@ -229,11 +234,12 @@ public async Task ASecondSessionOfOneConversation_IgnoresAStateBlobWrittenInASha Assert.Equal("intake", resumed.LastTurn!.StageBefore); Assert.Equal(1L, resumed.State.Read("turnsTaken")!.GetValue()); - ConversationEvent dropped = Assert.Single(observer.Events, fact => fact.Kind == ConversationEventKind.StateRestorePartial); + await resumed.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.StateRestorePartial); Assert.Equal( $"the stored state is version {ConversationSessionState.CurrentVersion + 1} " + $"and this build writes {ConversationSessionState.CurrentVersion}.", - dropped.Payload[ConversationEventPayloadKeys.Reason]); + dropped.Message); } [Fact] @@ -249,11 +255,11 @@ public async Task ASecondSessionOfAConversationWhoseDocumentLostItsPolicy_DropsT TestContext.Current.CancellationToken); using ScriptedChatClient reply = new("sure"); - RecordingObserver observer = new(); + RecordingHook hook = new(); // OneAgentYaml declares no policy: at all, which is the shape a document takes when its // policy section is removed between one session of a conversation and the next. - ConversationSession resumed = CreateSession(OneAgentYaml, reply, store, "C-nopolicy", observer); + ConversationSession resumed = CreateSession(OneAgentYaml, reply, store, "C-nopolicy", hook); _ = await resumed.RunTurnAsync("still there?", TestContext.Current.CancellationToken); @@ -261,10 +267,11 @@ public async Task ASecondSessionOfAConversationWhoseDocumentLostItsPolicy_DropsT // reserved slot anyway would hand the guards and the audit chain a stage nothing is in. Assert.Equal(string.Empty, resumed.Stage); - ConversationEvent dropped = Assert.Single(observer.Events, fact => fact.Kind == ConversationEventKind.StateRestorePartial); + await resumed.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.StateRestorePartial); Assert.Equal( "the entry declares no policy, so the stage 'intake' has nowhere to go.", - dropped.Payload[ConversationEventPayloadKeys.Reason]); + dropped.Message); } [Fact] @@ -303,8 +310,8 @@ public async Task ASecondSessionOfOneConversation_FallsBackWhenTheStageIsGone() TestContext.Current.CancellationToken); using ScriptedChatClient reply = new("sure"); - RecordingObserver observer = new(); - ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-nostage", observer); + RecordingHook hook = new(); + ConversationSession resumed = CreateSession(StagedYaml, reply, store, "C-nostage", hook); _ = await resumed.RunTurnAsync("still there?", TestContext.Current.CancellationToken); @@ -313,9 +320,10 @@ public async Task ASecondSessionOfOneConversation_FallsBackWhenTheStageIsGone() Assert.NotNull(resumed.LastTurn); Assert.Equal("intake", resumed.LastTurn!.StageBefore); - ConversationEvent dropped = Assert.Single(observer.Events, fact => fact.Kind == ConversationEventKind.StateRestorePartial); + await resumed.FlushNoticesAsync(); + Fault dropped = Assert.Single(hook.Of(), fault => fault.Kind == FaultKind.StateRestorePartial); Assert.Contains( - "a-stage-nobody-declares", dropped.Payload[ConversationEventPayloadKeys.Reason], StringComparison.Ordinal); + "a-stage-nobody-declares", dropped.Message, StringComparison.Ordinal); } [Fact] diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs index 6e0d39e3..8d6c836f 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs @@ -1,24 +1,23 @@ +using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; +using AgentCore.Domain.Audit; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { /// - /// What store 1 owes the chain: the words first, and a dropped write that costs the conversation nothing. + /// What a store that refuses a write costs the conversation: nothing, and never its end. /// - /// - /// The chain stores a hash of the spoken text and store 1 stores the text. That split only works in - /// one order — the words have to be there before the row that names their digest — and it only works - /// if a store that refuses a write cannot end the conversation it is recording. - /// public sealed class ConversationSessionStoreFailureTests { private const string OneAgentYaml = """ @@ -49,48 +48,17 @@ public sealed class ConversationSessionStoreFailureTests - { id: done, agent: only, terminal: true } """; - /// - /// The reply.interrupted row names a hash of the words the caller heard. Raised before - /// store 1 holds them, it names a hash of words nothing holds. - /// - [Fact] - public async Task Interrupt_MidReply_StoreOneHoldsTheWordsBeforeTheInterruptedFactIsRaised() - { - // Arrange - RecordingConversationStore store = new(); - using ScriptedChatClient reply = new("Hello", " there", " caller") { GateAfterFirstFragment = true }; - List whenTheFactWasRaised = []; - WatchingObserver observer = new( - ConversationEventKind.ReplyInterrupted, - conversationEvent => whenTheFactWasRaised.AddRange( - store.Live(conversationEvent.ConversationId).Select(row => row.Content.Text))); - ConversationSession session = CreateSession(OneAgentYaml, reply, store, observer); - (Task? turn, Task? spoke) = StartGatedTurn(session, "hi"); - await spoke; - - // Act - bool recorded = session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(300))); - - // Assert - reply.OpenGate(); - await turn; - Assert.True(recorded); - Assert.Equal(["hi", "Hello"], whenTheFactWasRaised); - } - - /// A store 1 write failure never ends a conversation. + /// A message store write failure never ends a conversation. [Fact] public async Task Append_StoreThrows_ConversationContinues() { - // Arrange using RequestRecordingChatClient reply = new("hi there", "it ships Friday"); ConversationSession session = CreateSession(OneAgentYaml, reply, new ThrowingConversationStore()); _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Act TurnResult second = await session.RunTurnAsync("order 41?", TestContext.Current.CancellationToken); - // Assert. The live history is the session's, so the turn after a dropped write still has the + // The live history is the session's, so the turn after a dropped write still has the // whole conversation. Only the durable copy was lost. Assert.Equal("it ships Friday", second.ReplyText); Assert.Equal( @@ -99,59 +67,24 @@ public async Task Append_StoreThrows_ConversationContinues() } /// - /// A turn that cannot re-read store 1 as it opens says so, and runs on the words it holds. - /// - [Fact] - public async Task Resync_StoreThrows_RaisesDiagnosticAndTheTurnRuns() - { - // Arrange - using RequestRecordingChatClient reply = new("hi there", "it ships Friday"); - RecordingObserver observer = new(); - ConversationSession session = CreateSession(OneAgentYaml, reply, new ThrowingConversationStore(), observer); - _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - - // Act - TurnResult second = await session.RunTurnAsync("order 41?", TestContext.Current.CancellationToken); - - // Assert - Assert.Equal("it ships Friday", second.ReplyText); - ConversationEvent failed = Assert.Single( - observer.Events, - conversationEvent => conversationEvent.Kind == ConversationEventKind.TranscriptResyncFailed); - Assert.Null(failed.EventId); - Assert.Equal(1, failed.TurnIndex); - } - - /// - /// A dropped write is a fact about the system and never about the conversation, so it is counted and - /// logged and stored nowhere. + /// A dropped write is a fact about the system and never about the conversation: the audit chain holds what it + /// would hold with no failure at all, and the dropped write itself takes no row. /// [Fact] - public async Task Append_StoreThrows_RaisesDiagnosticWithNoEventId() + public async Task Append_StoreThrows_TakesNoAuditRow() { - // Arrange using RequestRecordingChatClient reply = new("hi there"); - RecordingObserver observer = new(); - ConversationSession session = CreateSession(OneAgentYaml, reply, new ThrowingConversationStore(), observer); + InMemoryAuditSink sink = new(); + ConversationSession session = CreateSession( + OneAgentYaml, reply, new ThrowingConversationStore(), BuiltInHooks.Create(sink, NullLogger.Instance)); - // Act _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - - // Assert await session.FlushTranscriptAsync(); - ConversationEvent dropped = Assert.Single( - observer.Events, - conversationEvent => conversationEvent.Kind == ConversationEventKind.TranscriptWriteFailed); - Assert.Null(dropped.EventId); - Assert.Equal(0, dropped.TurnIndex); + await session.FlushNoticesAsync(); - // The facts that did take an identity are the ones the chain would have written with no - // failure at all: only the dropped write itself takes no row. - ConversationEvent[] stored = [.. observer.Events.Where(item => item.EventId is not null)]; - Assert.Equal( - [ConversationEventKind.ConversationStarted, ConversationEventKind.TurnCompleted], - stored.Select(item => item.Kind).ToArray()); - Assert.Equal(stored.Length, stored.Select(item => item.EventId).Distinct().Count()); + IReadOnlyList rows = sink.EventsOf(session.ConversationId); + Assert.Equal([AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], rows.Select(row => row.Kind)); + Assert.Equal(rows.Count, rows.Select(row => row.EventId).Distinct().Count()); } /// @@ -161,7 +94,6 @@ public async Task Append_StoreThrows_RaisesDiagnosticWithNoEventId() [Fact] public async Task Append_StoreThrowsOnTheTurnThatEndsTheConversation_TheNextTurnIsRefused() { - // Arrange using RequestRecordingChatClient reply = new("hi there", "goodbye", "still here?"); FlakyAppendConversationStore store = new(); ConversationSession session = CreateSession(EndsOnTheSecondTurnYaml, reply, store); @@ -171,17 +103,15 @@ public async Task Append_StoreThrowsOnTheTurnThatEndsTheConversation_TheNextTurn await session.FlushTranscriptAsync(); store.Down = false; - // Act InvalidOperationException refused = await Assert.ThrowsAsync( () => session.RunTurnAsync("one more", TestContext.Current.CancellationToken)); - // Assert Assert.Contains("runs no further turn", refused.Message, StringComparison.Ordinal); Assert.Equal(2, reply.Requests.Count); } private static ConversationSession CreateSession( - string yaml, IChatClient reply, IConversationStore store, params IConversationObserver[] observers) + string yaml, IChatClient reply, IConversationStore store, IReadOnlyList? hooks = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); CompiledAgent compiled = ConfigurationCompiler.CompileAll( @@ -192,77 +122,7 @@ private static ConversationSession CreateSession( compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null, - observers: observers).Create(); - } - - /// Starts a streaming turn on a background task and says when the caller can hear it. - private static (Task Turn, Task Spoke) StartGatedTurn(ConversationSession session, string userInput) - { - TaskCompletionSource spoke = new(TaskCreationOptions.RunContinuationsAsynchronously); - Task turn = Task.Run( - async () => - { - await foreach (ChatResponseUpdate? update in session - .RunTurnStreamingAsync(userInput, TestContext.Current.CancellationToken) - .ConfigureAwait(false)) - { - _ = spoke.TrySetResult(); - } - }, - CancellationToken.None); - - return (turn, spoke.Task.WaitAsync(TimeSpan.FromSeconds(10))); - } - - /// Keeps every fact of the conversation, in the order the turn loop raised them. - private sealed class RecordingObserver : IConversationObserver - { - private readonly Lock _gate = new(); - private readonly List _events = []; - - public IReadOnlyList Events - { - get - { - lock (_gate) - { - return [.. _events]; - } - } - } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - lock (_gate) - { - _events.Add(conversationEvent); - } - - return ValueTask.CompletedTask; - } - } - - /// - /// Reads the world at the moment one kind of fact is raised. - /// - /// - /// The dispatcher runs an observer that completes at once on the caller's thread, so what this - /// reads is what was true when the turn loop raised the fact, and not what became true later. - /// - private sealed class WatchingObserver(ConversationEventKind kind, Action look) : IConversationObserver - { - private readonly ConversationEventKind _kind = kind; - private readonly Action _look = look; - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - if (conversationEvent.Kind == _kind) - { - _look(conversationEvent); - } - - return ValueTask.CompletedTask; - } + hooks: hooks).Create(); } } } diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs index 2c0a4fdd..a31e0b5d 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; using AgentCore.Application.Tools.Binding; @@ -12,11 +11,13 @@ using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { /// - /// Pins the conversation's move onto one AgentSession: what the run is sent, and what store 1 keeps. + /// Pins the conversation's move onto one AgentSession: what the run is sent, and what the message store keeps. /// public sealed class ConversationSessionTranscriptTests { @@ -72,23 +73,20 @@ public sealed class ConversationSessionTranscriptTests private const string ToolResult = /*lang=json,strict*/ """{ "price": 50 }"""; /// - /// Item 6a and R4: the record holds the words the caller heard, and never the tail the model - /// produced. It is store 1 that must hold them, not only the live history. + /// The record holds the words the caller heard, and never the tail the model + /// produced. It is the message store that must hold them, not only the live history. /// [Fact] public async Task Interrupt_MidReply_StoredTranscriptHoldsHeardTextOnly() { - // Arrange RecordingConversationStore store = new(); using ScriptedChatClient reply = new("Hello", " there", " caller") { GateAfterFirstFragment = true }; ConversationSession session = CreateSession(OneAgentYaml, reply, store); (Task? turn, Task? spoke) = StartGatedTurn(session, "hi"); await spoke; - // Act bool recorded = session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(300))); - // Assert reply.OpenGate(); await turn; Assert.True(recorded); @@ -105,16 +103,13 @@ public async Task Interrupt_MidReply_StoredTranscriptHoldsHeardTextOnly() [Fact] public async Task InterruptAfterTheTurnEnded_ToolTurnWithProse_StoresTheHeardWordsOnce() { - // Arrange RecordingConversationStore store = new(); using ProseThenReplyChatClient reply = new("the price is fifty"); ConversationSession session = CreateSession(ToolYaml, reply, store, new StubToolBuilder(ToolResult).Create); await DrainAsync(session.RunTurnStreamingAsync("how much?", TestContext.Current.CancellationToken)); - // Act bool recorded = session.Cut(0, new TurnCut("the price", TimeSpan.FromMilliseconds(400))); - // Assert Assert.True(recorded); await session.FlushTranscriptAsync(); IReadOnlyList rows = store.Live(session.ConversationId); @@ -131,24 +126,21 @@ public async Task InterruptAfterTheTurnEnded_ToolTurnWithProse_StoresTheHeardWor } /// - /// Step 1's second failure mode: a cut that reached back a turn would replace a sentence the + /// A cut that reached back a turn would replace a sentence the /// caller heard in full, and nothing would detect it. The guard is ConversationSession's, so this /// drives it through rather than through the provider. /// [Fact] public async Task Interrupt_AfterASecondTurn_LeavesTheFirstTurnsReplyWhole() { - // Arrange RecordingConversationStore store = new(); RequestRecordingChatClient reply = new("hi there caller", "it ships Friday from the depot"); ConversationSession session = CreateSession(OneAgentYaml, reply, store); _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); _ = await session.RunTurnAsync("order 41?", TestContext.Current.CancellationToken); - // Act bool recorded = session.Cut(1, new TurnCut("it ships", TimeSpan.FromMilliseconds(500))); - // Assert Assert.True(recorded); await session.FlushTranscriptAsync(); Assert.Equal( @@ -159,15 +151,12 @@ public async Task Interrupt_AfterASecondTurn_LeavesTheFirstTurnsReplyWhole() [Fact] public async Task RunTurn_SecondTurn_SendsTheNewCallerMessageAloneAndTheModelStillSeesTheConversation() { - // Arrange RequestRecordingChatClient reply = new("hi there", "it ships Friday"); ConversationSession session = CreateSession(OneAgentYaml, reply, new RecordingConversationStore()); _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Act _ = await session.RunTurnAsync("order 41?", TestContext.Current.CancellationToken); - // Assert Assert.Equal( ["user:hello", "assistant:hi there", "user:order 41?"], reply.Requests[1]); @@ -175,20 +164,17 @@ public async Task RunTurn_SecondTurn_SendsTheNewCallerMessageAloneAndTheModelSti /// /// The reminder rides exactly one invocation, as instructions the framework merges and stores - /// nowhere. Nothing of it reaches the caller's own message, so store 1 keeps what was said. + /// nowhere. Nothing of it reaches the caller's own message, so the message store keeps what was said. /// [Fact] public async Task RunTurn_WithAnUnfilledSlot_KeepsTheReminderOutOfTheStoredTranscript() { - // Arrange RecordingConversationStore store = new(); RequestRecordingChatClient reply = new("which order?"); ConversationSession session = CreateSession(SlotYaml, reply, store); - // Act _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Assert // The reminder rides a message of its own, below the transcript, so the instructions block // stays byte-identical across turns and the vendor's cacheable prefix covers the transcript. Assert.DoesNotContain("", reply.Instructions[0] ?? string.Empty, StringComparison.Ordinal); @@ -222,11 +208,6 @@ private static ConversationSession CreateSession( /// The conversation to run the turn on. /// What the caller said. /// The running turn, and a task that completes at its first spoken update. - /// - /// A run that has handed the host nothing is not the turn the caller is hearing, so a barge-in - /// before the first update takes the amendment path instead and records nothing. Waiting for that - /// update is what makes the cut land in the reply rather than after it. - /// private static (Task Turn, Task Spoke) StartGatedTurn(ConversationSession session, string userInput) { TaskCompletionSource spoke = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -254,10 +235,6 @@ private static async Task DrainAsync(IAsyncEnumerable update } /// Writes a line beside the tool call it announces, then answers once the result lands. - /// - /// This is what a real model produces, and it is the shape a cut has to survive: the prose and - /// the conversation ride one message, so the words and the side effect cannot be dropped together. - /// private sealed class ProseThenReplyChatClient(string reply) : IChatClient { /// The line the model speaks before it calls the tool. diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs index 0558f218..11fa1eb5 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs @@ -15,7 +15,7 @@ public void Append_NamesEveryRow_AndKeepsTheNameTheCallerGaveTheFirst() { ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; - IReadOnlyList rows = transcript.Append([User("hello"), Assistant("hi")], firstMessageId: "caller-1"); + IReadOnlyList rows = transcript.Append([User("hello"), Assistant("hi")], userMessageId: "caller-1"); Assert.Equal("caller-1", rows[0].MessageId); @@ -39,7 +39,7 @@ public void TruncateFrom_DropsTheTailAndLeavesTheMarksWhereTheyAre() Assert.Equal(new WithdrawnTurns(1, 1), went); Assert.Equal(["q1", "a1"], transcript.Read().Select(message => message.Text)); - // The withdrawn places stay spent. Store 3 is append-only and still holds rows against the + // The withdrawn places stay spent. The store is append-only and still holds rows against the // turns that stood in them, so reissuing either number would put two turns in one place. Assert.Equal(4, transcript.NextOrdinal); Assert.Equal(1, transcript.TurnIndex); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs index d7f1f430..6d5058c7 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs @@ -5,7 +5,7 @@ namespace AgentCore.Application.Tests.Transcript { - /// Pins the rules of store 1: ordinals, which reply a barge-in cuts, and what a cut keeps. + /// Pins the rules of the message store: ordinals, which reply a barge-in cuts, and what a cut keeps. public sealed class ConversationTranscriptTests { [Fact] diff --git a/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs b/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs new file mode 100644 index 00000000..7ca894a7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs @@ -0,0 +1,87 @@ +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Transcript.AgentCoreChatHistoryProviderTestSupport; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// A phone turn that carries what the caller and the vendor's own voice said ahead of it: those lines are the + /// turn's first rows, in speech order, and never the agent's reply. Words from a real GPT-Live call. + /// + public sealed class FrontVoiceCommitTests + { + private static readonly IReadOnlyList SaidBefore = + [ + new(ChatRole.User, "Uh, who are you"), + FrontVoice.Line("You're speaking with Sole Fitness's virtual assistant."), + new(ChatRole.User, "Um, do you know what day it is today"), + FrontVoice.Line("It's Wednesday."), + ]; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task CommitTurn_WritesTheLinesSaidBeforeAheadOfTheUser_AndNamesTheUserRow() + { + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); + provider.BeginTurn(session, 0); + + TurnWrite? written = provider.CommitTurn(session, EndTheCall()); + await provider.DrainAsync(session); + + Assert.Equal( + [ + ("user", null, "Uh, who are you"), + ("assistant", FrontVoice.AuthorName, "You're speaking with Sole Fitness's virtual assistant."), + ("user", null, "Um, do you know what day it is today"), + ("assistant", FrontVoice.AuthorName, "It's Wednesday."), + ("user", null, "Okay, uh please end the call now"), + ("assistant", null, "Today is Sunday. Goodbye!"), + ], + store.Rows.Select(row => (row.Content.Role.Value, row.Content.AuthorName, row.Content.Text))); + Assert.Equal(1, store.Appends); + Assert.Equal(("d3", store.Rows[^1].MessageId), (written!.UserMessageId, written.ReplyMessageId)); + Assert.Equal("d3", store.Rows[4].MessageId); + } + + // The turn's reply hash and TurnCompleted's reply are the agent's words only. + [Fact] + public async Task CommitTurn_SpokenHoldsOnlyTheAgentsReply() + { + (AgentCoreChatHistoryProvider provider, _, StubSession session) = await NewConversation(); + provider.BeginTurn(session, 0); + + TurnWrite? written = provider.CommitTurn(session, EndTheCall()); + + Assert.Equal("Today is Sunday. Goodbye!", written!.Spoken); + } + + // A cut of the agent's reply lays the heard words over the agent's own rows, never over a line already spoken. + [Fact] + public async Task RewriteReply_LeavesTheFrontVoiceLinesAsTheyWere() + { + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); + provider.BeginTurn(session, 0); + _ = provider.CommitTurn(session, EndTheCall()); + + bool rewritten = provider.RewriteReply(session, 0, "Today is"); + await provider.DrainAsync(session); + + Assert.True(rewritten); + Assert.Equal( + ["Uh, who are you", "You're speaking with Sole Fitness's virtual assistant.", "Um, do you know what day it is today", "It's Wednesday.", "Okay, uh please end the call now", "Today is"], + store.Live(ConversationId).Select(row => row.Content.Text)); + } + + private static TurnCommit EndTheCall() => + new(new ChatMessage(ChatRole.User, "Okay, uh please end the call now")) + { + Before = SaidBefore, + Seen = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Today is Sunday. Goodbye!")), + UserMessageId = "d3", + }; + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs b/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs index 8fd408d7..2081485e 100644 --- a/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs +++ b/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs @@ -22,6 +22,9 @@ internal sealed class HeldFirstTurnChatClient(string heldWords) : IChatClient /// Gets a value indicating whether the held request, once released, throws instead of answering. public bool FaultsHeldRequest { get; init; } + /// Gets a value indicating whether the held request, once released, answers with no text. + public bool EmptiesHeldRequest { get; init; } + /// Gets the last request the model was sent. public IReadOnlyList LastRequest { get; private set; } = []; @@ -46,6 +49,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { throw new HttpRequestException(DownModelChatClient.Message); } + + if (EmptiesHeldRequest) + { + yield break; + } } string id = Guid.NewGuid().ToString("N"); diff --git a/tests/AgentCore.Application.Tests/Transcript/InnerAgentTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/InnerAgentTranscriptTests.cs index 2aa4e3d9..58c5419a 100644 --- a/tests/AgentCore.Application.Tests/Transcript/InnerAgentTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/InnerAgentTranscriptTests.cs @@ -1,10 +1,10 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Tests.Transcript { @@ -33,8 +33,8 @@ public sealed class InnerAgentTranscriptTests """; /// - /// The inner agent runs on a session of its own, which no BeginConversation ever named, so store 1 - /// never sees its rounds. Keeping them would cost tokens on every later turn and would put the + /// The inner agent runs on a session of its own, which no BeginConversation ever named, so the + /// conversation's transcript never sees its rounds. Keeping them would cost tokens on every later turn and would put the /// inner agent's working-out into the audit record, which no consumer asked for. /// [Fact] @@ -55,10 +55,8 @@ public async Task ADelegatingTurn_WritesTheOuterRoundsOnly() await session.FlushTranscriptAsync(); - // Outer tool call, inner answer, outer reply. The old count was two: the inner run - // never reached its own model, so a kind: agent tool never heard its agent. Three is - // the delegation actually working — and the rows below still hold, so its working-out - // stays off store 1 all the same. + // Outer tool call, inner answer, outer reply: the inner agent's model is reached, and its + // working-out still stays off the outer conversation's rows. Assert.Equal(3, model.Calls); Assert.Equal( ["user", "assistant", "tool", "assistant"], diff --git a/tests/AgentCore.Application.Tests/Transcript/RefusedTurnAccountingTests.cs b/tests/AgentCore.Application.Tests/Transcript/RefusedTurnAccountingTests.cs new file mode 100644 index 00000000..f13f80cf --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/RefusedTurnAccountingTests.cs @@ -0,0 +1,147 @@ +using System.Diagnostics.Metrics; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Hooks; +using AgentCore.Application.Tests.Diagnostics; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// A turn the store refused leaves the same failure counter and log line as a turn that committed: the run that + /// faulted or answered nothing is counted and logged once, built-in hooks and all. The counter is process-wide, so + /// the class runs alone. + /// + [Collection(TelemetryHookSuite.Name)] + public sealed class RefusedTurnAccountingTests + { + private const string ConversationId = "c-refused-accounting"; + private const string FailureInstrument = "agentcore.turn.failures"; + private const string FailureKey = "agentcore.failure.kind"; + + // EventIds from Diagnostics/Log.cs. + private const int EmptyReplyEvent = 3; + private const int TurnRunFaultedEvent = 28; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 30_000)] + public async Task ARefusedTurnWhoseRunFaulted_IsCountedAndLoggedAsARunFaultOnce() + { + HeldFirstTurnChatClient reply = new("I am Alice") { FaultsHeldRequest = true }; + using RecordingLoggerFactory logs = new(); + using FailureCounter counter = new(); + + Exception? refused = await RunRefusedTurnAsync(reply, logs); + + _ = Assert.IsType(refused); + Assert.Equal(1, counter.Of("run")); + Assert.Equal(0, counter.Of("empty_reply")); + CapturedLine line = Assert.Single(logs.Of(TurnRunFaultedEvent)); + Assert.Equal(LogLevel.Error, line.Level); + _ = Assert.IsType(line.Exception); + } + + [Fact(Timeout = 30_000)] + public async Task ARefusedTurnThatAnsweredNothing_IsCountedAndLoggedAsAnEmptyReplyOnce() + { + HeldFirstTurnChatClient reply = new("I am Alice") { EmptiesHeldRequest = true }; + using RecordingLoggerFactory logs = new(); + using FailureCounter counter = new(); + + Exception? refused = await RunRefusedTurnAsync(reply, logs); + + _ = Assert.IsType(refused); + Assert.Equal(1, counter.Of("empty_reply")); + Assert.Equal(0, counter.Of("run")); + _ = Assert.Single(logs.Of(EmptyReplyEvent)); + } + + [Fact(Timeout = 30_000)] + public async Task ACommittedTurnWhoseRunFaulted_IsStillCountedAndLoggedOnce() + { + using DownModelChatClient model = new(); + using RecordingLoggerFactory logs = new(); + using FailureCounter counter = new(); + ConversationSession session = TurnObservabilityHarness.Build( + ConversationSessionTestSupport.OneAgentYaml, model, null, logger: logs.CreateLogger("session")).Create(ConversationId); + + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + Assert.Equal(1, counter.Of("run")); + _ = Assert.Single(logs.Of(TurnRunFaultedEvent)); + } + + private static async Task RunRefusedTurnAsync(HeldFirstTurnChatClient reply, RecordingLoggerFactory logs) + { + ILogger logger = logs.CreateLogger("session"); + ConversationSessionFactory factory = ConversationSessionRefusedCatchUpTests.Build( + reply, + new ConversationSessionRefusedCatchUpTests.UnmarkableStore(new InMemoryConversationStore()), + logger, + BuiltInHooks.Create(new InMemoryAuditSink(), logger)); + ConversationSession a = factory.Create(ConversationId); + ConversationSession b = factory.Create(ConversationId); + + Task alice = a.RunTurnAsync("I am Alice", Ct); + await reply.Held.Task.WaitAsync(Ct); + _ = await b.RunTurnAsync("I am Bob", Ct); + reply.Release(); + Exception? refused = await Record.ExceptionAsync(() => alice); + await a.FlushNoticesAsync(); + await b.FlushNoticesAsync(); + return refused; + } + + /// Counts agentcore.turn.failures by kind from construction on. + private sealed class FailureCounter : IDisposable + { + private readonly Dictionary _counts = new(StringComparer.Ordinal); + private readonly MeterListener _listener = new(); + + public FailureCounter() + { + _listener.InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal) + && string.Equals(instrument.Name, FailureInstrument, StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback((_, value, tags, _) => + { + KeyValuePair[] copy = tags.ToArray(); + string kind = copy.First(tag => string.Equals(tag.Key, FailureKey, StringComparison.Ordinal)).Value?.ToString() ?? string.Empty; + lock (_counts) + { + _counts[kind] = _counts.GetValueOrDefault(kind) + value; + } + }); + _listener.Start(); + } + + public long Of(string kind) + { + lock (_counts) + { + return _counts.GetValueOrDefault(kind); + } + } + + public void Dispose() + { + _listener.Dispose(); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs b/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs index 19f38008..08f61c1a 100644 --- a/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs @@ -1,15 +1,14 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Cut; namespace AgentCore.Application.Tests.Transcript { /// /// The words a cut or failed turn of two steps writes: the user's message, step one's words before its tool - /// call, the finished pair, then only the shown part of step two, or the fallback line (design section 3; owner - /// rulings 2026-09-23). + /// call, the finished pair, then only the shown part of step two, or the fallback line. /// public sealed class TurnWordsTests { @@ -23,7 +22,6 @@ public sealed class TurnWordsTests private const string Fallback = "Sorry, the order service did not answer."; - // Probe T2_TextStreamAbortInSecondStep_KeepsAllYieldedText. [Fact] public void Compose_StoppedInStepTwo_KeepsEveryYieldedWordInItsStep() { @@ -36,7 +34,6 @@ public void Compose_StoppedInStepTwo_KeepsEveryYieldedWordInItsStep() _ = Assert.Single(words[2].Contents.OfType()); } - // Probe T3_TextStreamAbortAfterToolResultBeforeStepTwoText_KeepsStepOneText. [Fact] public void Compose_StoppedAfterTheToolResultBeforeStepTwoSpoke_KeepsStepOne() { @@ -46,7 +43,6 @@ public void Compose_StoppedAfterTheToolResultBeforeStepTwoSpoke_KeepsStepOne() _ = Assert.Single(words[1].Contents.OfType()); } - // Probe V5_BargeInInSecondStep_KeepsBothStepsHeardText. [Fact] public void Compose_HeardEndsInsideStepTwo_KeepsStepOneAndCutsStepTwo() { diff --git a/tests/AgentCore.AspNetCore.Tests/AgentCore.AspNetCore.Tests.csproj b/tests/AgentCore.AspNetCore.Tests/AgentCore.AspNetCore.Tests.csproj index 66d5fd4c..9e4dc7f6 100644 --- a/tests/AgentCore.AspNetCore.Tests/AgentCore.AspNetCore.Tests.csproj +++ b/tests/AgentCore.AspNetCore.Tests/AgentCore.AspNetCore.Tests.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs new file mode 100644 index 00000000..5c0dbd50 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs @@ -0,0 +1,375 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Calls +{ + /// A call's asks, and a correction while one is open. + public sealed class PhoneCallAskTests + { + private const string Question = "Max speed of the F63?"; + + private const string Correction = "Oh wait, I meant the F80"; + + private const string EntryFallback = "Sorry, I lost you there. Please call back."; + + private const string EntryFallbackYaml = """ + apiVersion: agentcore/v1 + fallbackReply: "The document's own fallback." + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "answer the caller" } + entries: + main: + agent: only + fallbackReply: "Sorry, I lost you there. Please call back." + """; + + // The fast tool comes first, so it has returned by the time the slow one holds the round. + private const string TwoToolsYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: stock_lookup, kind: builtin, uses: orders.read, description: "Look up the stock of an item." } + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "answer the caller", tools: [ stock_lookup, price_lookup ] } + entries: + main: + agent: only + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AnAskIsAnsweredWithTheModelsReply() + { + StallOnCueChatClient model = new(); + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(model, []); + using (harness) + { + CallAnswer answer = await call.Asks.AskAsync("d1", "What are your hours?", [], NoTools, Ct); + + Assert.Equal(CallAnswer.Answered("reply to What are your hours?"), answer); + } + } + + // Once the older answer went out, a correction is a turn of its own. + [Fact] + public async Task ACorrectionAfterTheOlderAnswerKeepsTheOlderTurn() + { + RecordingHook hook = new(); + StallOnCueChatClient model = new(); + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(model, [hook]); + using (harness) + { + _ = await call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + CallAnswer second = await call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + await call.Session.FlushNoticesAsync(); + + Assert.Equal(CallAnswer.Answered("reply to " + Correction), second); + Assert.Equal([Question, Correction], UserWords(model.Requests[^1])); + Assert.Empty(hook.Of()); + } + } + + // Tool progress goes to the vendor as it happens. + [Fact] + public async Task EachToolCallIsReportedAsProgress() + { + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(new GatedToolCallingChatClient(), []); + using (harness) + { + List tools = []; + + _ = await call.Asks.AskAsync("d1", "How much is the F80?", [], name => { tools.Add(name); return default; }, Ct); + + Assert.Equal(["price_lookup"], tools); + } + } + + // The caller hung up, so the running ask is withdrawn and the call ends once. + [Fact(Timeout = 10_000)] + public async Task AHangUpWithdrawsTheRunningAsk() + { + RecordingHook hook = new(); + StallOnCueChatClient model = new() { HoldFirst = true }; + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(model, [hook]); + using (harness) + { + Task asking = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await model.FirstEntered.Task; + + await call.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await call.Session.FlushNoticesAsync(); + + Assert.Equal(CallAnswer.Withdrawn, await asking); + Assert.Equal("close_requested", Assert.Single(hook.Of()).Call!.Cause); + } + } + + // The caller hears the engine's own fallback for the entry: its fallbackReply over the document's. + [Fact] + public async Task AnAskAfterTheCallEndedGetsTheEntrysFallbackReply() + { + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(new StallOnCueChatClient(), [], yaml: EntryFallbackYaml); + using (harness) + { + await call.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + + CallAnswer answer = await call.Asks.AskAsync("d9", "hello?", [], NoTools, Ct); + + Assert.Equal(new CallAnswer(CallAnswerKind.Fallback, EntryFallback), answer); + } + } + + [Fact] + public async Task AFaultWhileTheTurnRunsGetsTheFallbackReply() + { + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(new GatedToolCallingChatClient(), []); + using (harness) + { + CallAnswer answer = await call.Asks.AskAsync("d1", "How much is the F80?", [], _ => throw new IOException("the vendor socket closed"), Ct); + + Assert.Equal(new CallAnswer(CallAnswerKind.Fallback, AgentCoreConfiguration.DefaultFallbackReply), answer); + } + } + + // A store or HTTP timeout cancels with a token that is not the ask's: a fault, not a withdraw. + [Fact] + public async Task ATimeoutWhileTheSessionReopensGetsTheFallbackReply() + { + GatedConversationSessions? sessions = null; + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync( + new StallOnCueChatClient(), [], host => host with { Sessions = sessions = new GatedConversationSessions(host.Sessions) }); + using (harness) + { + sessions!.Fault(new TaskCanceledException("the store timed out")); + + CallAnswer answer = await call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + + Assert.Equal(new CallAnswer(CallAnswerKind.Fallback, AgentCoreConfiguration.DefaultFallbackReply), answer); + } + } + + // Asks take their place in the order they arrive, even while the older one still waits for its session. + [Fact(Timeout = 10_000)] + public async Task ACorrectionWhileTheOlderAskWaitsForItsSessionReplacesIt() + { + GatedConversationSessions? sessions = null; + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync( + new StallOnCueChatClient(), [], host => host with { Sessions = sessions = new GatedConversationSessions(host.Sessions) }); + using (harness) + { + sessions!.Arm(); + Task older = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await sessions.Entered.Task; + + CallAnswer newer = await call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + sessions.Release(); + + Assert.Equal(CallAnswer.Withdrawn, await older); + Assert.Equal(CallAnswer.Answered("reply to " + Question + " " + Correction), newer); + } + } + + // The correction replaces the unanswered question; the model reads the whole thought once. + [Fact(Timeout = 10_000)] + public async Task ACorrectionWhileTheOlderAskRunsReplacesIt() + { + RecordingHook hook = new(); + StallOnCueChatClient model = new() { HoldFirst = true }; + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(model, [hook]); + using (harness) + { + Task older = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await model.FirstEntered.Task; + + CallAnswer newer = await call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + await call.Session.FlushNoticesAsync(); + + Assert.Equal(CallAnswer.Withdrawn, await older); + Assert.Equal(CallAnswer.Answered("reply to " + Question + " " + Correction), newer); + Assert.Equal([Question + " " + Correction], UserWords(model.Requests[^1])); + Assert.DoesNotContain(call.Session.Transcript, message => message.Text == Question); + _ = Assert.Single(hook.Of()); + } + } + + // The withdrawn ask's running tool finishes, and the correction's + // turn reads its result instead of running it again. + [Fact(Timeout = 10_000)] + public async Task ACorrectionWhileTheOlderAsksToolRunsLetsItFinishOnceAndTheNewTurnReadsItsResult() + { + GatedTool tool = new(); + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync( + new GatedToolCallingChatClient(), [], tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id)); + using (harness) + { + Task older = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await tool.Entered.Task; + + Task newer = call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + tool.Release.SetResult(); + + Assert.Equal(CallAnswer.Withdrawn, await older); + Assert.Equal(CallAnswer.Answered("done."), await newer); + Assert.Equal((1, 1), (tool.Runs, tool.Finished)); + Assert.Contains( + call.Session.Transcript.SelectMany(message => message.Contents).OfType(), + result => result.Result?.ToString() == GatedTool.Result); + Assert.DoesNotContain(call.Session.Transcript, message => message.Text == Question); + } + } + + // When the older ask's tool outlives its withdraw: the correction's turn waits for the tool and reads its + // result. The tool is held until the correction's turn reached the model, which only a turn that did not wait + // does; a turn that waits gets the release from the bound instead. + [Fact(Timeout = 10_000)] + public async Task ACorrectionWhoseOlderAsksToolOutlivesTheWithdrawStillReadsItsResult() + { + GatedTool tool = new(); + GatedToolCallingChatClient model = new(); + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync( + model, [], tools: declared => tool.Create(declared.Id, declared.Description ?? declared.Id)); + using (harness) + { + Task older = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await tool.Entered.Task; + + Task newer = call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + Assert.Equal(CallAnswer.Withdrawn, await older); + _ = await Task.WhenAny(model.SecondRequest.Task, Task.Delay(TimeSpan.FromSeconds(1), Ct)); + tool.Release.SetResult(); + + Assert.Equal(CallAnswer.Answered("done."), await newer); + Assert.Equal((1, 1), (tool.Runs, tool.Finished)); + } + } + + // The call that returned before its sibling was carried reached no response + // message, so it is kept too, and the correction's turn runs neither tool again. + [Fact(Timeout = 10_000)] + public async Task ACorrectionReadsTheResultOfACallThatFinishedBeforeItsCarriedSibling() + { + GatedTool price = new(); + int stockRuns = 0; + MissingToolsChatClient model = new(); + string Stock() + { + _ = Interlocked.Increment(ref stockRuns); + return "{\"stock\":3}"; + } + + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync( + model, + [], + yaml: TwoToolsYaml, + tools: declared => declared.Id == "stock_lookup" + ? AIFunctionFactory.Create(Stock, declared.Id, declared.Description) + : price.Create(declared.Id, declared.Description ?? declared.Id)); + using (harness) + { + Task older = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await price.Entered.Task; + + Task newer = call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + Assert.Equal(CallAnswer.Withdrawn, await older); + _ = await Task.WhenAny(model.SecondRequest.Task, Task.Delay(TimeSpan.FromSeconds(1), Ct)); + price.Release.SetResult(); + + Assert.Equal(CallAnswer.Answered("done."), await newer); + Assert.Equal((1, 1), (stockRuns, price.Runs)); + } + } + + // A call whose round wrote its response messages rides the withdrawn turn's own rows; a later round's carry must + // not keep it a second time. + [Fact(Timeout = 10_000)] + public async Task ACarryInALaterRoundKeepsNoCallOfAnEarlierRoundAgain() + { + GatedTool price = new(); + MissingToolsChatClient model = new() { OnePerRound = true }; + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync( + model, + [], + yaml: TwoToolsYaml, + tools: declared => declared.Id == "stock_lookup" + ? AIFunctionFactory.Create(() => "{\"stock\":3}", declared.Id, declared.Description) + : price.Create(declared.Id, declared.Description ?? declared.Id)); + using (harness) + { + Task older = call.Asks.AskAsync("d1", Question, [], NoTools, Ct); + await price.Entered.Task; + + Task newer = call.Asks.AskAsync("d2", Correction, [], NoTools, Ct); + Assert.Equal(CallAnswer.Withdrawn, await older); + price.Release.SetResult(); + + Assert.Equal(CallAnswer.Answered("done."), await newer); + Assert.Single( + call.Session.Transcript.SelectMany(message => message.Contents).OfType(), + result => result.Result?.ToString() == "{\"stock\":3}"); + } + } + + // What the front voice handled ahead of each ask rides the resend once, ahead of the caller's whole thought. + [Fact(Timeout = 10_000)] + public async Task ACorrectionWhileTheOlderAskRunsCarriesWhatWasSaidAheadOfBothAsksOnce() + { + StallOnCueChatClient model = new() { HoldFirst = true }; + (PhoneCallHarness harness, PhoneCall call) = await StartedAsync(model, []); + using (harness) + { + Task older = call.Asks.AskAsync("d1", Question, [new(ChatRole.User, "Hi"), FrontVoice.Line("Hello! How can I help?")], NoTools, Ct); + await model.FirstEntered.Task; + + CallAnswer newer = await call.Asks.AskAsync("d2", Correction, [new(ChatRole.User, "Are you there?"), FrontVoice.Line("Yes, one moment.")], NoTools, Ct); + _ = await older; + + (string, string?, string)[] said = + [ + ("user", null, "Hi"), + ("assistant", FrontVoice.AuthorName, "Hello! How can I help?"), + ("user", null, "Are you there?"), + ("assistant", FrontVoice.AuthorName, "Yes, one moment."), + ("user", null, Question + " " + Correction), + ]; + Assert.Equal(CallAnswer.Answered("reply to " + Question + " " + Correction), newer); + Assert.Equal(said, Spoken(model.Requests[^1])); + Assert.Equal([.. said, ("assistant", "only", "reply to " + Question + " " + Correction)], Spoken(call.Session.Transcript)); + } + } + + private static ValueTask NoTools(string name) => default; + + private static List<(string Role, string? Author, string Text)> Spoken(IEnumerable messages) => + [.. messages.Where(message => message.Role != ChatRole.System).Select(message => (message.Role.Value, message.AuthorName, message.Text))]; + + private static List UserWords(IReadOnlyList request) => + [.. request.Where(message => message.Role == ChatRole.User).Select(message => message.Text)]; + + private static async Task<(PhoneCallHarness Harness, PhoneCall Call)> StartedAsync( + IChatClient model, + IReadOnlyList hooks, + Func? hostOf = null, + string yaml = PhoneCallHarness.OneEntryYaml, + Func? tools = null) + { + PhoneCallHarness harness = PhoneCallHarness.Create(model, hooks, yaml, tools: tools); + PhoneCallHost host = hostOf is null ? harness.Host : hostOf(harness.Host); + PhoneCall call = (await PhoneCall.AdmitAsync(host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + return (harness, call); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs new file mode 100644 index 00000000..c3d21ac3 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs @@ -0,0 +1,165 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Tests.Calls +{ + /// + /// A started call raises once, however it leaves, and one that never started raises + /// none, as it raised no . + /// + public sealed class PhoneCallEndedNoticeTests + { + private static readonly DateTimeOffset Noon = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The host's shutdown ends a call the same way a hang-up does, only with its own cause. + [Theory] + [InlineData(ConversationEndReason.CallerHungUp, "close_requested")] + [InlineData(ConversationEndReason.Faulted, "shutdown")] + public async Task ACallThatEndsItsConversationEndsOnceAfterConversationEnded(ConversationEndReason reason, string cause) + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook], time: time); + PhoneCall call = await StartedAsync(harness); + time.Advance(TimeSpan.FromSeconds(30)); + + await call.EndAsync(reason, cause); + await call.CloseAsync(); + await call.Session.FlushNoticesAsync(); + + Assert.Equal(("call-7", 30d, (string?)cause, CallEndReason.Ended), Shape(Assert.Single(hook.Of()))); + Assert.Equal([typeof(ConversationEnded), typeof(CallEnded)], EndsInOrder(hook)); + } + + // The engine ended the conversation while the call stayed up; the hang-up after it still reaches the hooks. + [Fact] + public async Task ACallThatHangsUpAfterTheEngineEndedItsConversationStillEnds() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook]); + PhoneCall call = await StartedAsync(harness); + _ = call.Session.EndConversation(ConversationEndReason.AgentCompleted); + await call.Session.FlushNoticesAsync(); + + await call.EndAsync(ConversationEndReason.AgentCompleted, "agent_ended"); + await call.Session.FlushNoticesAsync(); + + CallEnded ended = Assert.Single(hook.Of()); + Assert.Equal(("agent_ended", CallEndReason.Ended), (ended.Cause, ended.Reason)); + } + + // An end asked for while a turn runs waits for that turn; the call's end still comes after it. + [Fact] + public async Task AnEndThatWaitsOnTheRunningTurnStillComesBeforeTheCallsEnd() + { + RecordingHook hook = new(); + StallOnCueChatClient model = new() { HoldFirst = true }; + using PhoneCallHarness harness = PhoneCallHarness.Create(model, [hook]); + PhoneCall call = await StartedAsync(harness); + using CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task turn = call.Session.RunTurnAsync("hi", stop.Token); + await model.FirstEntered.Task.WaitAsync(Ct); + + await call.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await stop.CancelAsync(); + _ = await Record.ExceptionAsync(() => turn); + await call.Session.FlushNoticesAsync(); + + Assert.Equal([typeof(ConversationEnded), typeof(CallEnded)], EndsInOrder(hook)); + } + + [Fact] + public async Task ACallThatLeavesWithoutAnEndIsClosedAndItsConversationGoesOnUnended() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook], time: time); + PhoneCall call = await StartedAsync(harness); + ConversationSession session = call.Session; + time.Advance(TimeSpan.FromSeconds(4)); + + await call.CloseAsync(); + await session.FlushNoticesAsync(); + + Assert.Equal(("call-7", 4d, (string?)null, CallEndReason.Closed), Shape(Assert.Single(hook.Of()))); + Assert.Empty(hook.Of()); + } + + [Fact] + public async Task ACallThatNeverStartedRaisesNoEnd() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook]); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + ConversationSession session = call.Session; + + await call.AbandonAsync(); + await session.FlushNoticesAsync(); + + Assert.Empty(hook.Of()); + Assert.Empty(hook.Of()); + Assert.Empty(hook.Of()); + } + + // A takeover ends the older admission as replaced; the newer one ends normally later. + [Fact] + public async Task ATakeoverEndsTheOlderAdmissionAsReplacedAndTheNewerEndsNormallyLater() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook], time: time); + PhoneCall older = await StartedAsync(harness); + time.Advance(TimeSpan.FromSeconds(5)); + + PhoneCall newer = await StartedAsync(harness); + await older.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + await older.CloseAsync(); + time.Advance(TimeSpan.FromSeconds(7)); + await newer.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await newer.Session.FlushNoticesAsync(); + + Assert.Equal( + [("call-7", 5d, (string?)null, CallEndReason.Replaced), ("call-7", 12d, "close_requested", CallEndReason.Ended)], + hook.Of().Select(Shape)); + } + + // A call whose conversation another call took after an unload leaves as lost, and ends nothing of that call's. + [Fact] + public async Task ACallWhoseConversationAnotherCallTookLeavesAsLost() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook, new CallDecider(gate => gate.Accept("thread-9"))]); + PhoneCall stale = await StartedAsync(harness, "call-7"); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + PhoneCall newer = await StartedAsync(harness, "call-8"); + + await stale.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await stale.CloseAsync(); + await newer.Session.FlushNoticesAsync(); + + CallEnded ended = Assert.Single(hook.Of()); + Assert.Equal(("call-7", CallEndReason.Lost), (ended.CallId, ended.Reason)); + Assert.Empty(hook.Of()); + } + + private static async Task StartedAsync(PhoneCallHarness harness, string callId = "call-7") + { + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(callId), Ct)).Call!; + await call.StartAsync(Ct); + return call; + } + + private static (string CallId, double Seconds, string? Cause, CallEndReason Reason) Shape(CallEnded ended) => + (ended.CallId, ended.Seconds, ended.Cause, ended.Reason); + + private static Type[] EndsInOrder(RecordingHook hook) => + [.. hook.Notices.Where(notice => notice is ConversationEnded or CallEnded).Select(notice => notice.GetType())]; + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs new file mode 100644 index 00000000..01dde22b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs @@ -0,0 +1,45 @@ +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Calls +{ + /// + /// A vendor that speaks for itself (GPT-Live) puts its own lines in the history; every turn of its calls is told + /// they were already heard, whether or not the call has a brief. A vendor that does not is told nothing. + /// + public sealed class PhoneCallFrontVoiceTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AFrontVoiceCallWithNoBriefStillTellsItsTurnsTheNote() + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, []); + + await AskAsync(harness, harness.Host with { FrontVoice = true }); + + Assert.EndsWith("\n" + CallBriefHook.FrontVoiceNote, model.Instructions[^1], StringComparison.Ordinal); + } + + [Fact] + public async Task ACallWhoseVendorDoesNotSpeakForItselfIsToldNoNote() + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, []); + + await AskAsync(harness, harness.Host); + + Assert.DoesNotContain(CallBriefHook.FrontVoiceNote, model.Instructions[^1], StringComparison.Ordinal); + } + + private static async Task AskAsync(PhoneCallHarness harness, PhoneCallHost host) + { + PhoneCall call = (await PhoneCall.AdmitAsync(host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + _ = await call.Asks.AskAsync("d1", "What are your hours?", [], _ => default, Ct); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs new file mode 100644 index 00000000..8d7e6533 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs @@ -0,0 +1,70 @@ +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Calls +{ + /// + /// LiveKit's duplicate-identity rule: a newer call with the same call id takes the + /// conversation over, and the older call is told and can no longer end or close it. + /// + public sealed class PhoneCallTakeoverTests + { + private const string Brief = "Ask about the treadmill order."; + + private static readonly DateTimeOffset Noon = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task ANewerCallWithTheSameIdTakesTheConversationOverAndTheOlderOneCannotEndIt() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, [hook, new CallDecider(gate => gate.Accept("thread-9", Brief))], time: time); + List told = []; + PhoneCall older = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct, told.Add)).Call!; + await older.StartAsync(Ct); + time.Advance(TimeSpan.FromSeconds(5)); + + PhoneCall newer = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + await newer.StartAsync(Ct); + await older.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + await older.CloseAsync(); + + Assert.Equal([older], told); + Assert.Same(newer.Session, await harness.Sessions.TryGetAsync("main", "thread-9", Ct)); + Assert.False(newer.Session.Lifetime.Ending.Requested); + _ = await newer.Session.RunTurnAsync("hi", Ct); + Assert.Contains(Brief, model.Instructions[^1], StringComparison.Ordinal); + + time.Advance(TimeSpan.FromSeconds(7)); + await newer.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await newer.Session.FlushNoticesAsync(); + + // One call: one start and one end, its length counted from the older connection's start. + Assert.Equal(new CallEnd("call-7", 12, "close_requested"), Assert.Single(hook.Of()).Call); + Assert.Single(hook.Of()); + } + + // A call that already left its conversation (its end or close began) is not taken over: the conversation is + // ending, so a reconnect is refused as busy until the close is done. + [Fact] + public async Task ACallThatAlreadyEndedIsNotTakenOver() + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), []); + PhoneCall older = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + await older.StartAsync(Ct); + await older.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + + PhoneCallAdmission newer = await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct); + + Assert.Equal(CallRefusal.Busy, newer.Refusal); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs new file mode 100644 index 00000000..30450f78 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs @@ -0,0 +1,385 @@ +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Tests.Calls +{ + /// One call, whatever the vendor. + public sealed class PhoneCallTests + { + private const string Brief = "Ask about the treadmill order."; + + private static readonly DateTimeOffset Noon = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // No hook accepts under the call id. The conversation starts before the call does. + [Fact] + public async Task WithNoHookTheCallJoinsAConversationNamedByItsCallIdAndStartsAfterIt() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook]); + + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + await call.Session.FlushNoticesAsync(); + + Assert.Equal("call-7", call.ConversationId); + Assert.Equal([typeof(ConversationStarted), typeof(CallStarted)], hook.Notices.Select(notice => notice.GetType())); + Assert.Equal(ConversationOrigin.New, hook.Of().Single().Origin); + CallStarted started = hook.Of().Single(); + Assert.Equal(("call-7", "+15550100", "+15550199", "test-transport"), (started.CallId, started.From, started.To, started.Transport)); + } + + [Fact] + public async Task ARejectingHookRefusesTheCallAndOpensNothing() + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [new CallDecider(gate => gate.Reject(CallRefusal.Declined))]); + + PhoneCallAdmission admission = await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct); + + Assert.Equal(new PhoneCallAdmission(null, CallRefusal.Declined), admission); + Assert.Null(await harness.Sessions.TryGetAsync("main", "call-7", Ct)); + } + + // An id another entry holds refuses the call as busy. + [Fact] + public async Task AnIdHeldByAnotherEntryRefusesTheCallAsBusy() + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [new CallDecider(gate => gate.Accept("thread-1"))]); + _ = await harness.Sessions.GetOrOpenAsync("other", "thread-1", state: null, Ct); + + PhoneCallAdmission admission = await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct); + + Assert.Equal(CallRefusal.Busy, admission.Refusal); + } + + // One live call per conversation: ending or letting go of one call must never end another's. + [Fact] + public async Task AConversationAnotherCallHoldsRefusesASecondCallAsBusy() + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [new CallDecider(gate => gate.Accept("thread-1"))]); + PhoneCall first = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + + PhoneCallAdmission second = await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-8"), Ct); + + Assert.Equal(((PhoneCall?)null, (CallRefusal?)CallRefusal.Busy, true), (second.Call, second.Refusal, second.HeldByCall)); + Assert.Same(first.Session, await harness.Sessions.TryGetAsync("main", "thread-1", Ct)); + } + + // The brief reaches the engine as instructions while the call lives, and is gone after it. + [Fact] + public async Task TheBriefOfAnAcceptedCallReachesTheEngineUntilTheCallEnds() + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, [new CallDecider(gate => gate.Accept("thread-9", Brief))]); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + + _ = await call.Session.RunTurnAsync("hi", Ct); + await call.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + + Assert.Contains(Brief, model.Instructions[^1], StringComparison.Ordinal); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + + // An ended conversation runs no further turn, so the later run is a new conversation under the same id. + await call.Session.Compiled.ConversationStore.DeleteAsync("thread-9", Ct); + Assert.DoesNotContain(Brief, await InstructionsOfALaterRunAsync(harness, model, "thread-9"), StringComparison.Ordinal); + } + + // A call can leave without an end (a second setup frame closes it) or before the vendor took it. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ACallThatLeavesWithoutAnEndLeavesNoBriefOnTheConversation(bool vendorTookTheCall) + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, [new CallDecider(gate => gate.Accept("thread-9", Brief))]); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + + if (vendorTookTheCall) + { + await call.StartAsync(Ct); + await call.CloseAsync(); + } + else + { + await call.AbandonAsync(); + } + + Assert.DoesNotContain(Brief, await InstructionsOfALaterRunAsync(harness, model, "thread-9"), StringComparison.Ordinal); + } + + // The id moved on after an unload. The stale call's brief goes; a newer call owns the brief state. + [Theory] + [InlineData(false, null)] + [InlineData(true, "Newer brief.")] + [InlineData(true, null)] + public async Task AStaleCallsBriefNeverReachesWhatNowHoldsTheConversation(bool aCallHoldsTheIdNow, string? newerBrief) + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + using PhoneCallHarness harness = PhoneCallHarness.Create( + model, [new CallDecider(gate => gate.Accept("thread-9", gate.CallId == "call-7" ? "Stale brief." : newerBrief))]); + PhoneCall stale = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + await stale.StartAsync(Ct); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + if (aCallHoldsTheIdNow) + { + await (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-8"), Ct)).Call!.StartAsync(Ct); + } + else + { + _ = await harness.Sessions.GetOrOpenAsync("main", "thread-9", state: null, Ct); + } + + string before = await InstructionsOfALaterRunAsync(harness, model, "thread-9"); + await stale.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + string after = await InstructionsOfALaterRunAsync(harness, model, "thread-9"); + + Assert.DoesNotContain("Stale brief.", after, StringComparison.Ordinal); + Assert.Equal(newerBrief is not null, after.Contains("Newer brief.", StringComparison.Ordinal)); + if (aCallHoldsTheIdNow) + { + Assert.Equal(after, before); + } + } + + // One live call per conversation: a call whose conversation another call took after an unload has lost it. + [Fact] + public async Task ACallThatComesBackToAConversationALaterCallTookHasLostIt() + { + FakeTimeProvider time = new(Noon); + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create( + model, [hook, new CallDecider(gate => gate.Accept("thread-9", gate.CallId == "call-7" ? "Stale brief." : "Newer brief."))], time: time); + PhoneCall stale = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + await stale.StartAsync(Ct); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + PhoneCall newer = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-8"), Ct)).Call!; + await newer.StartAsync(Ct); + time.Advance(TimeSpan.FromSeconds(12)); + + _ = await Assert.ThrowsAsync(() => stale.LiveSessionAsync(Ct).AsTask()); + + _ = await newer.Session.RunTurnAsync("hi", Ct); + Assert.Contains("Newer brief.", model.Instructions[^1], StringComparison.Ordinal); + await newer.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await newer.Session.FlushNoticesAsync(); + Assert.Equal(new CallEnd("call-8", 12, "close_requested"), Assert.Single(hook.Of()).Call); + } + + // One live call per conversation: an admitted call whose conversation unloaded starts on it again, unless a + // later call took it. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AnAdmittedCallStartsOnItsReopenedConversationUnlessALaterCallTookIt(bool aCallTookIt) + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create( + model, [hook, new CallDecider(gate => gate.Accept("thread-9", gate.CallId == "call-7" ? "Stale brief." : "Newer brief."))]); + PhoneCall stale = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + if (aCallTookIt) + { + await (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-8"), Ct)).Call!.StartAsync(Ct); + _ = await Assert.ThrowsAsync(() => stale.StartAsync(Ct).AsTask()); + } + else + { + await stale.StartAsync(Ct); + Assert.Same(await harness.Sessions.TryGetAsync("main", "thread-9", Ct), stale.Session); + } + + string seen = await InstructionsOfALaterRunAsync(harness, model, "thread-9"); + Assert.Contains(aCallTookIt ? "Newer brief." : "Stale brief.", seen, StringComparison.Ordinal); + Assert.DoesNotContain(aCallTookIt ? "Stale brief." : "Newer brief.", seen, StringComparison.Ordinal); + await stale.Session.FlushNoticesAsync(); + Assert.Equal(aCallTookIt ? ["call-8"] : ["call-7"], hook.Of().Select(started => started.CallId)); + } + + // The caller hung up while the store opened: the call never starts, so no brief is filed and no start is raised. + [Fact] + public async Task AnEndWhileTheStoreOpensStopsTheStart() + { + GatedCreateStore store = new(); + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, [hook, new CallDecider(gate => gate.Accept("thread-9", Brief))], store: store); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + Task start = call.StartAsync(Ct).AsTask(); + await store.Entered.Task.WaitAsync(Ct); + + await call.EndAsync(ConversationEndReason.CallerHungUp, cause: null); + store.Open.SetResult(); + + _ = await Assert.ThrowsAsync(() => start); + await call.Session.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + await call.Session.Compiled.ConversationStore.DeleteAsync("thread-9", Ct); + Assert.DoesNotContain(Brief, await InstructionsOfALaterRunAsync(harness, model, "thread-9"), StringComparison.Ordinal); + } + + // An end that lands after the store opened, as the start stamps its time, still leaves no brief and no start. + // The host clock is read once in the start, at its stamp, so its first read is the end. + [Fact] + public async Task AnEndAsTheStartStampsItsTimeLeavesNoBriefAndNoStart() + { + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(model, [hook, new CallDecider(gate => gate.Accept("thread-9", Brief))]); + PhoneCall? call = null; + Task ending = Task.CompletedTask; + PhoneCallHost host = harness.Host with { Time = new ActingTimeProvider(() => ending = call!.EndAsync(ConversationEndReason.CallerHungUp, cause: null).AsTask()) }; + call = (await PhoneCall.AdmitAsync(host, PhoneCallHarness.Offer(), Ct)).Call!; + + await call.StartAsync(Ct); + await ending; + + await call.Session.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + await call.Session.Compiled.ConversationStore.DeleteAsync("thread-9", Ct); + Assert.DoesNotContain(Brief, await InstructionsOfALaterRunAsync(harness, model, "thread-9"), StringComparison.Ordinal); + } + + // A call starts once, and never after it left. + [Theory] + [InlineData("started")] + [InlineData("abandoned")] + [InlineData("ended")] + public async Task AStartAfterTheCallStartedOrLeftThrows(string before) + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), []); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await (before switch + { + "started" => call.StartAsync(Ct), + "abandoned" => call.AbandonAsync(), + _ => call.EndAsync(ConversationEndReason.CallerHungUp, cause: null), + }); + + _ = await Assert.ThrowsAsync(() => call.StartAsync(Ct).AsTask()); + } + + [Fact] + public async Task HeardLinesBecomeLineSpokenInOrder() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook]); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + + await call.HeardAsync(Speaker.Caller, "Hi there", Noon, Noon.AddSeconds(1)); + await call.HeardAsync(Speaker.Agent, "Hey! How can I help?", Noon.AddSeconds(2), Noon.AddSeconds(4)); + await call.Session.FlushNoticesAsync(); + + Assert.Equal( + [(Speaker.Caller, "Hi there", Noon, Noon.AddSeconds(1)), (Speaker.Agent, "Hey! How can I help?", Noon.AddSeconds(2), Noon.AddSeconds(4))], + hook.Of().Select(line => (line.Speaker, line.Text, line.StartedAt, line.EndedAt))); + } + + // After an unload, a heard line follows the call's conversation: onto a session no call holds, never into a later call's. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ALineHeardAfterAnUnloadNeverReachesALaterCallsConversation(bool aCallTookIt) + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook, new CallDecider(gate => gate.Accept("thread-9"))]); + PhoneCall stale = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-7"), Ct)).Call!; + await stale.StartAsync(Ct); + await harness.Sessions.CloseAsync("main", "thread-9", Ct); + ConversationSession holder = aCallTookIt + ? await StartedSessionAsync(harness, "call-8") + : await harness.Sessions.GetOrOpenAsync("main", "thread-9", state: null, Ct); + + await stale.HeardAsync(Speaker.Caller, "Is anyone there?", Noon, Noon.AddSeconds(1)); + await holder.FlushNoticesAsync(); + + Assert.Equal(aCallTookIt ? [] : ["Is anyone there?"], hook.Of().Select(line => line.Text)); + Assert.Equal(!aCallTookIt, ReferenceEquals(holder, stale.Session)); + } + + // Once per conversation, with the call's id, length, and the vendor's cause. + [Fact] + public async Task AnEndIsRaisedOnceWithTheCallsLengthAndCause() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook], time: time); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + time.Advance(TimeSpan.FromSeconds(30)); + + await call.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await call.EndAsync(ConversationEndReason.Faulted, "shutdown"); + await call.Session.FlushNoticesAsync(); + + ConversationEnded ended = Assert.Single(hook.Of()); + Assert.Equal(ConversationEndReason.CallerHungUp, ended.Reason); + Assert.Equal(new CallEnd("call-7", 30, "close_requested"), ended.Call); + } + + // A store that times out cancels with its own token, not the call's: the end and the close still go through, + // on the call's own session, so the conversation is ended and released. + [Fact] + public async Task AStoreTimeoutDuringTheEndStillEndsAndReleasesTheConversation() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook]); + GatedConversationSessions store = new(harness.Sessions); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host with { Sessions = store }, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + store.FaultTryGets(new TaskCanceledException("The store timed out.")); + + await call.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await call.CloseAsync(); + await call.Session.FlushNoticesAsync(); + + Assert.Equal(ConversationEndReason.CallerHungUp, Assert.Single(hook.Of()).Reason); + Assert.Null(await harness.Sessions.TryGetAsync("main", "call-7", Ct)); + } + + // The conversation reopened after an unload is still the call's, so its end names the call. + [Fact] + public async Task TheEndOfAReopenedSessionStillNamesTheCall() + { + FakeTimeProvider time = new(Noon); + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook], time: time); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + await harness.Sessions.CloseAsync("main", "call-7", Ct); + time.Advance(TimeSpan.FromSeconds(20)); + + ConversationSession reopened = await call.LiveSessionAsync(Ct); + await call.EndAsync(ConversationEndReason.CallerHungUp, "close_requested"); + await reopened.FlushNoticesAsync(); + + Assert.Equal(new CallEnd("call-7", 20, "close_requested"), Assert.Single(hook.Of()).Call); + } + + private static async Task StartedSessionAsync(PhoneCallHarness harness, string callId) + { + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(callId), Ct)).Call!; + await call.StartAsync(Ct); + return call.Session; + } + + private static async Task InstructionsOfALaterRunAsync(PhoneCallHarness harness, InstructionsSeenChatClient model, string conversationId) + { + ConversationSession later = await harness.Sessions.GetOrOpenAsync("main", conversationId, state: null, Ct); + _ = await later.RunTurnAsync("hello again", Ct); + return model.Instructions[^1]; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs b/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs index a69485b6..174059b3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs @@ -1,10 +1,10 @@ -using AgentCore.TestSupport; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.AspNetCore.Vendors.TelnyxRelay; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Xunit; @@ -14,26 +14,16 @@ namespace AgentCore.AspNetCore.Tests.Conversation /// /// providers.conversation is selected while the host starts, and the pairing rule runs there too. /// - /// - /// - /// Registering a vendor is what turns this seam on, exactly as it is for telemetry, knowledge, - /// moderation, and speech. Every test here starts a host alone, with no route - /// mapped anywhere, because agreement between two document entries is a document fact and is true - /// or false whether or not anything is ever routed. - /// - /// - /// Every test runs offline against a fake model. There is no Telnyx account, no network conversation, and - /// no API key anywhere in this file. - /// - /// public sealed class ConversationRegistrationTests { /// A conversation transport that is a name and a wire fact, which is all the port asks for. - private sealed class FakeConversationAdapter(string kind, bool carriesText) : IConversationAdapter + private sealed class FakeConversationAdapter(string kind, bool carriesText, CallTraits traits = CallTraits.None) : IConversationAdapter { public string Kind { get; } = kind; public bool CarriesText { get; } = carriesText; + + public CallTraits Traits { get; } = traits; } [Fact] @@ -81,13 +71,11 @@ public async Task ADocumentNamingAnUnregisteredConversationKindFailsTheStart() Assert.Equal("/providers/conversation/kind", failure.Errors[0].Pointer); } - // --------------------------------------------------------------------------------------------- - // The two blocks are required by the schema, so a loaded document that writes a providers - // section carries both. A configuration a host built in code passes through no schema at all, + // The schema requires the conversation block, so a loaded document that writes a providers + // section carries it. A configuration a host built in code passes through no schema at all, // and so does a loaded document that writes no providers section: the root requires only // apiVersion and name. Both routes reach the guard with a block missing, and both are refused // by a message that names the block rather than by a NullReferenceException. - // --------------------------------------------------------------------------------------------- [Fact] public async Task AConfigurationBuiltInCodeWithNoConversationBlockFailsTheStart() { @@ -122,9 +110,44 @@ public async Task AConfigurationBuiltInCodeWithNoSpeechBlockFailsTheStart() Assert.Equal("/providers/speech", failure.Errors[0].Pointer); } - // --------------------------------------------------------------------------------------------- - // Helpers. - // --------------------------------------------------------------------------------------------- + // A vendor that speaks for itself needs no providers.speech. + [Fact] + public async Task AnAdapterThatSpeaksForItselfStartsWithNoSpeechBlock() + { + using IHost host = await BuildFromAsync( + InCode(new ProvidersConfiguration + { + Llm = OneModel, + Conversation = new ConversationProviderConfiguration { Kind = "openai-live" }, + }), + new FakeConversationAdapter("openai-live", carriesText: true, CallTraits.SpeaksForItself)); + + Assert.NotNull(host.Services.GetService>()); + } + + // A vendor that takes turns itself never runs AgentCore's fillers. + [Fact] + public async Task AVendorThatTakesTurnsItselfRefusesAToolFiller() + { + ConfigurationLoadException failure = await Assert.ThrowsAsync( + async () => await BuildFromAsync( + InCode(new ProvidersConfiguration + { + Llm = OneModel, + Conversation = new ConversationProviderConfiguration + { + Kind = "openai-live", + Filler = new Dictionary(StringComparer.Ordinal) + { + ["price_lookup"] = new() { Say = "One moment.", DelaySeconds = 1 }, + }, + }, + }), + new FakeConversationAdapter("openai-live", carriesText: true, CallTraits.SpeaksForItself | CallTraits.TakesTurnsItself))); + + Assert.Equal("/providers/conversation/filler", failure.Errors[0].Pointer); + } + /// The one model every document here names, so the compile has something to resolve. private static IReadOnlyList OneModel { get; } = @@ -191,11 +214,6 @@ private static Task BuildAsync( /// The document, loaded or built in code. /// The conversation transports this host registers, if any. /// The composed container. - /// - /// UseConversation is called only when this host has something to register. Calling it with an - /// empty list would turn the seam on for a host that registered no vendor, which is the one - /// thing these tests prove does not happen. - /// private static async Task BuildFromAsync( AgentCoreConfiguration configuration, params IConversationAdapter[] adapters) diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreAuditTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreAuditTests.cs new file mode 100644 index 00000000..4763e2b0 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreAuditTests.cs @@ -0,0 +1,193 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Audit; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.Application.Runtime.Session; +using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; + +namespace AgentCore.AspNetCore.Tests.DependencyInjection +{ + /// The audit sink the composition root opens, queues, and drains on shutdown. + public sealed class AddAgentCoreAuditTests + { + // The audit chain shuts down with the host. An event is ACCEPTED when AppendAsync returns, so a + // stop that does not drain the queue loses every row still in it. + [Fact] + public async Task AddAgentCore_ClosesTheAuditStoreOnlyAfterTheQueueHasDrained() + { + (IHost? host, ClosingAuditSink? store) = await BuildAuditHostAsync(); + + await host.StartAsync(TestContext.Current.CancellationToken); + + await host.Services + .GetRequiredService() + .AppendAsync(AuditRow(1), TestContext.Current.CancellationToken); + + await host.StopAsync(TestContext.Current.CancellationToken); + + // The container closes what it resolved before it closes the boot that still owns the store + // behind the queue. Closing that store first would hand the drain a store which can no + // longer accept the rows it already promised to keep. + host.Dispose(); + + Assert.True(store.Closed); + Assert.Equal(1, store.WrittenWhenClosed); + } + + + // The same agent, and a document that names the built-in memory kind on purpose. + private const string MemoryAuditYaml = + $$""" + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + {{MinimalProviders}} + audit: { kind: memory } + entries: + main: + agent: only + """; + + // The same agent, served by an audit vendor the host registers itself. + private const string VendorAuditYaml = + $$""" + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + {{MinimalProviders}} + audit: { kind: test } + entries: + main: + agent: only + """; + + [Fact] + public async Task TheDefaultAuditSink_ReachesTheTurnLoopAndTheChainVerifies() + { + using StartedHost provider = await BuildAsync(OneAgentYaml); + + ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // The queue is what keeps the append off the turn, so the rows land on a thread of their own + // and a reader that wants them now asks for them now. + await Queue(provider).FlushAsync(TestContext.Current.CancellationToken); + + IReadOnlyList events = Sink(provider).EventsOf("conversation-1"); + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], + events.Select(item => item.Kind).ToArray()); + Assert.All(events, AuditEventVocabulary.Validate); + } + + [Fact] + public async Task ADocumentThatNamesNoAuditProvider_StillOpensTheMemorySink() + { + using StartedHost provider = await BuildAsync(OneAgentYaml); + + // The turn loop produces audit events whatever a document says, so the seam that receives + // them has a working default rather than a null. That is what lets every reading of a conversation be + // unconditional, and what lets a first run and a test work with no database. + Assert.NotNull(provider.GetService()); + _ = Assert.IsType(provider.GetRequiredService().Store); + } + + [Fact] + public async Task ADocumentThatNamesTheMemoryKind_OpensTheSameSinkAsNamingNothing() + { + using StartedHost provider = await BuildAsync(MemoryAuditYaml); + + // memory is this library's own name and it needs no registered vendor, so writing it says out + // loud what leaving the block out does quietly. The startup warning is the difference. + _ = Assert.IsType(provider.GetRequiredService().Store); + } + + [Fact] + public async Task AnAuditVendorTheDocumentNames_IsTheStoreBehindTheQueue() + { + RecordingAuditSink store = new(); + using StartedHost provider = await BuildAsync( + VendorAuditYaml, + options => options.UseAuditSinks(new TestAuditSinkAdapter(store))); + + ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + await Queue(provider).FlushAsync(TestContext.Current.CancellationToken); + + // The host lists its vendors once and providers.audit.kind picks one, exactly as the five + // seams beside it. Nothing but the document decides which store the chain lands in. + Assert.Same(store, provider.GetRequiredService().Store); + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], + store.Events.Select(item => item.Kind).ToArray()); + } + + [Fact] + public async Task AnAuditKindThisHostDoesNotRegister_FailsTheStart() + { + // A document that asked for something this host cannot give fails while the host starts, and + // never on a conversation. The message names the kind, exactly as every other vendor seam. + ConfigurationLoadException failure = await Assert.ThrowsAsync( + () => BuildAsync(VendorAuditYaml)); + + Assert.Contains("test", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TheAuditSink_IsWrappedInTheQueueThatKeepsItOffTheTurn() + { + using StartedHost provider = await BuildAsync(OneAgentYaml); + + // A durable insert costs about 13 ms p50 against 91 nanoseconds to enqueue, so the queue is applied + // once, here, to whatever the document opened. An adapter that blocks on its database is + // therefore correct, and no adapter carries a queue of its own. + _ = Assert.IsType(provider.GetRequiredService()); + } + + /// Reads back the queue the composition root put in front of the document's store. + internal static QueuedAuditSink Queue(IServiceProvider provider) + { + return Assert.IsType(provider.GetRequiredService()); + } + + /// Reads back the store itself, which is registered under its own concrete type. + internal static InMemoryAuditSink Sink(IServiceProvider provider) + { + return Assert.IsType(provider.GetRequiredService().Store); + } + + private static async Task<(IHost Host, ClosingAuditSink Store)> BuildAuditHostAsync() + { + ClosingAuditSink store = new(); + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new()); + ConfigureServices( + builder.Services, + VendorAuditYaml, + options => options.UseAuditSinks(new TestAuditSinkAdapter(store))); + + return (builder.Build(), store); + } + + /// One well-formed event, which is all a drain has to carry. + /// Seconds past the epoch the event occurred at, so callers can order rows. + /// The event. + private static AuditEvent AuditRow(long secondsPastEpoch) + { + return new() + { + ConversationId = "conversation-1", + EventId = Guid.CreateVersion7(), + Kind = AuditEventKind.TurnCompleted, + OccurredAt = DateTimeOffset.UnixEpoch.AddSeconds(secondsPastEpoch), + }; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreHookTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreHookTests.cs new file mode 100644 index 00000000..55ed7150 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreHookTests.cs @@ -0,0 +1,74 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; +using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Tests.DependencyInjection +{ + /// The host's own hooks, bound through the composition root. + public sealed class AddAgentCoreHookTests + { + [Fact] + public async Task AHostHook_HearsTheFactsOfATurn() + { + RecordingHook first = new(); + RecordingHook second = new(); + using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.UseHooks(first, second)); + + ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + await session.FlushNoticesAsync(); + + // Every hook of a conversation hears the same facts, and the library's own are neither replaced nor bypassed. + _ = Assert.Single(first.Of()); + _ = Assert.Single(first.Of()); + _ = Assert.Single(second.Of()); + _ = Assert.Single(second.Of()); + } + + [Fact] + public async Task AHostHookThatThrows_CostsNeitherTheTurnNorTheChain() + { + using StartedHost provider = await BuildAsync( + OneAgentYaml, + options => options.UseHooks(new ThrowingHook())); + + ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // A notice hook records the conversation and is never a part of it. That holds for the host's own, and + // it holds for the readings registered beside it: a broken host hook does not cost the + // audit chain a single row. + Assert.Equal("hello", turn.ReplyText); + + await AddAgentCoreAuditTests.Queue(provider).FlushAsync(TestContext.Current.CancellationToken); + + IReadOnlyList events = AddAgentCoreAuditTests.Sink(provider).EventsOf("conversation-1"); + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], + events.Select(item => item.Kind).ToArray()); + Assert.All(events, AuditEventVocabulary.Validate); + } + + /// A hook that refuses every notice it is offered, so the isolation of the seam is observable. + private sealed class ThrowingHook : AgentHook + { + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) + { + throw new InvalidOperationException("the host's hook is broken"); + } + + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + throw new InvalidOperationException("the host's hook is broken"); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs index 028250e7..0d66b001 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs @@ -3,8 +3,6 @@ using AgentCore.Application.Tools.Registry; using AgentCore.TestSupport; using AgentCore.Application.Sessions.Memory; -using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Audit; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; @@ -16,10 +14,7 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Transcript; -using AgentCore.AspNetCore.DependencyInjection; -using AgentCore.AspNetCore.Sessions; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.Domain.Audit; using AgentCore.Domain.Knowledge; using AgentCore.Infrastructure.Tools; using Microsoft.Extensions.AI.Evaluation; @@ -33,36 +28,15 @@ using Microsoft.Agents.AI; using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; using AgentCore.Domain; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.DependencyInjection { /// /// The composition root. It loads, validates, resolves, compiles, and registers, in that order. /// - /// - /// A configuration defect stops the host at start and never on the first conversation. Every test proves - /// that by starting a host and nothing else, with no request anywhere. - /// public sealed class AddAgentCoreTests { - /// The conversation and speech providers every document below shares. - private const string SpeechAndConversation = - """ - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - """; - - /// plus the single reply model most documents declare. - private const string MinimalProviders = - $$""" - {{SpeechAndConversation}} - llm: - - { kind: openai, model: gpt-4.1-mini, as: reply } - """; - // The same agent, reachable on two entries. private const string TwoEntryYaml = $$""" @@ -182,8 +156,8 @@ public sealed class AddAgentCoreTests agent: only """; - // Row 4 of the section 8.2 compile table, with a guarded edge on each exit of the start node. - // Check 5 proves the two guards exclusive, so exactly one edge fires for each conversation. + // A guarded edge on each exit of the start node. + // Load-time validation proves the two guards exclusive, so exactly one edge fires for each conversation. private const string GuardedGraphYaml = $$""" apiVersion: agentcore/v1 @@ -218,7 +192,7 @@ public sealed class AddAgentCoreTests - { kind: openai, model: gpt-4.1-mini, as: bot } """; - // A stage names a target that policy.stages does not declare, so check 2 fails the load. + // A stage names a target that policy.stages does not declare, so validation fails the load. private const string BrokenYaml = $$""" apiVersion: agentcore/v1 @@ -336,7 +310,7 @@ public sealed class AddAgentCoreTests """; // BrokenYaml's structural defect (an unreachable policy transition), plus an mcp: server whose - // command names a binary that does not exist. Decision 15's whole point is that the structural + // command names a binary that does not exist. The structural // error below must surface without AgentCore ever trying to reach that server: a missing // executable fails Process.Start synchronously, so if discovery ran first this would instead // report the MCP failure. See AddAgentCore_TheStructuralFaultSurfaces_BeforeMcpIsEverAsked. @@ -360,9 +334,6 @@ public sealed class AddAgentCoreTests - { id: start, agent: only, to: [ { stage: nowhere } ] } """; - // ------------------------------------------------------------------------------------------- - // What it registers. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AddAgentCore_RegistersTheCompiledAgentAsAProcessSingleton() { @@ -529,10 +500,8 @@ public async Task AddAgentCore_RegistersTheSessionRegistryAsAPublicPort() Assert.Null(provider.GetService()); } - // ------------------------------------------------------------------------------------------- // Telemetry shuts down with the host. The container owns the session, so its disposal is the // flush — which is the one path a start that failed also reaches. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AddAgentCore_RegistersTheTelemetrySessionTheDocumentNames() { @@ -587,49 +556,6 @@ public async Task AddAgentCore_FlushesTheTelemetrySessionOnceWhenTheHostIsDispos Assert.Equal(1, adapter.Session.Flushes); } - // ------------------------------------------------------------------------------------------- - // The audit chain shuts down with the host. An event is ACCEPTED when AppendAsync returns, so a - // stop that does not drain the queue loses every row still in it. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task AddAgentCore_DrainsTheAuditQueueWhenTheHostShutsDown() - { - (IHost? host, ClosingAuditSink? store) = await BuildAuditHostAsync(); - - await host.StartAsync(TestContext.Current.CancellationToken); - - await host.Services - .GetRequiredService() - .AppendAsync(AuditRow(1), TestContext.Current.CancellationToken); - - await host.StopAsync(TestContext.Current.CancellationToken); - host.Dispose(); - - Assert.Equal(1, store.Written); - } - - [Fact] - public async Task AddAgentCore_ClosesTheAuditStoreOnlyAfterTheQueueHasDrained() - { - (IHost? host, ClosingAuditSink? store) = await BuildAuditHostAsync(); - - await host.StartAsync(TestContext.Current.CancellationToken); - - await host.Services - .GetRequiredService() - .AppendAsync(AuditRow(1), TestContext.Current.CancellationToken); - - await host.StopAsync(TestContext.Current.CancellationToken); - - // The container closes what it resolved before it closes the boot that still owns the store - // behind the queue. Closing that store first would hand the drain a store which can no - // longer accept the rows it already promised to keep. - host.Dispose(); - - Assert.True(store.Closed); - Assert.Equal(1, store.WrittenWhenClosed); - } - [Fact] public async Task AddAgentCore_ClosesTheConversationStoreWhenTheHostShutsDown() { @@ -701,9 +627,7 @@ public async Task AHostRegisteredDisposableToolSource_IsClosedWhenTheHostShutsDo Assert.True(source.Disposed); } - // ------------------------------------------------------------------------------------------- // The document picks the vendor, and no code names one: the point of the adapter seam. - // ------------------------------------------------------------------------------------------- [Fact] public async Task TheAdapterOverload_LetsTheDocumentPickTheVendorByItsKind() { @@ -777,9 +701,6 @@ public async Task AToolSource_SeesTheChatClientFactoryAlreadyBuilt() Assert.Same(builtFactory, seenWhenToolsWereBuilt); } - // ------------------------------------------------------------------------------------------- - // The seams the host binds. - // ------------------------------------------------------------------------------------------- [Fact] public async Task ABindingTool_ReachesTheDelegateTheHostRegistered() { @@ -837,9 +758,6 @@ public async Task ASecretReferenceWithNoResolver_FailsTheStartAndNamesTheSecret( Assert.Equal("orders-api-key", failure.SecretName); } - // ------------------------------------------------------------------------------------------- - // Row 4 of the compile table, through the only supported composition root. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AGuardedGraph_Starts() { @@ -847,7 +765,7 @@ public async Task AGuardedGraph_Starts() CompiledAgent compiled = provider.GetRequiredService>()["main"]; - // The document passes all eight checks and now compiles too. AddAgentCore binds the guard + // The document passes all eight checks and compiles too. AddAgentCore binds the guard // evaluator, so a guarded edge is reachable from here. Assert.Equal(CompiledAgentShape.ExplicitGraph, compiled.Shape); Assert.Equal("main", compiled.Name); @@ -890,9 +808,7 @@ public async Task AGuardedGraph_KeepsTwoConversationsApartWhenTheyRunAtTheSameTi Assert.Equal(1, provider.GetRequiredService().CompileCount); } - // ------------------------------------------------------------------------------------------- - // The evaluation seam of D13. Triage row T18 defers the online path, so the rate is 0. - // ------------------------------------------------------------------------------------------- + // The online evaluation path is closed by default, so the sample rate is 0. [Fact] public async Task AddAgentCore_RegistersTheEvaluationSeamWithTheOnlinePathClosed() { @@ -901,11 +817,10 @@ public async Task AddAgentCore_RegistersTheEvaluationSeamWithTheOnlinePathClosed EvaluatorRegistry registry = provider.GetRequiredService(); EvaluationSampler sampler = provider.GetRequiredService(); - // D13 names fault_code, and it calls no model, so it is the one evaluator that is safe by - // default. + // fault_code calls no model, so it is the one evaluator that is safe by default. Assert.True(registry.Contains("fault_code")); - // T18: a judge must never block a turn, and the offline gate has not proved the evaluators + // A judge must never block a turn, and the offline gate has not proved the evaluators // yet. A rate of 0 draws no number and conversations nothing. Assert.Equal(0, sampler.Rate); Assert.False(sampler.ShouldSample()); @@ -950,8 +865,7 @@ public async Task AddAgentCore_BuildsTheModerationVendorTheDocumentNames() EvaluatorRegistry registry = provider.GetRequiredService(); - // The same object serves the turn loop and the offline golden set, which is what D13 means - // by an evaluator written once and used twice. + // The same object serves the turn loop and the offline golden set: an evaluator is written once and used twice. Assert.Equal(1, adapter.Builds); Assert.True(registry.Contains(PromptModerator.ModerationEvaluatorName)); Assert.True(registry.Contains("fault_code")); @@ -1022,14 +936,12 @@ public async Task AddAgentCore_TakesTheSampleRateTheDocumentSets() EvaluationSampler sampler = provider.GetRequiredService(); - // T18: the rate comes from evaluation.sampleRate, and the composition root reads it. + // The rate comes from evaluation.sampleRate, and the composition root reads it. Assert.Equal(1, sampler.Rate); Assert.True(sampler.ShouldSample()); } - // ------------------------------------------------------------------------------------------- - // The spoken fallback of section 8.7, from the document to the caller. - // ------------------------------------------------------------------------------------------- + // The spoken fallback, from the document to the caller. [Fact] public async Task AQuietTurn_SpeaksTheFallbackTheDocumentNames() { @@ -1070,9 +982,6 @@ public async Task AddAgentCore_KeepsAnEvaluationServiceTheHostRegisteredFirst() Assert.Same(mine, provider.GetRequiredService()); } - // ------------------------------------------------------------------------------------------- - // The conversation titler: one model, named by the document like every other model. - // ------------------------------------------------------------------------------------------- // The same agent, and a document that gives the titler a model of its own. private const string TitlerYaml = @@ -1148,141 +1057,6 @@ public async IAsyncEnumerable GenerateFromAsync( } } - // ------------------------------------------------------------------------------------------- - // The audit sink: named by providers.audit, and never absent. - // ------------------------------------------------------------------------------------------- - - // The same agent, and a document that names the built-in memory kind on purpose. - private const string MemoryAuditYaml = - $$""" - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "I answer everything" } - {{MinimalProviders}} - audit: { kind: memory } - entries: - main: - agent: only - """; - - // The same agent, served by an audit vendor the host registers itself. - private const string VendorAuditYaml = - $$""" - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "I answer everything" } - {{MinimalProviders}} - audit: { kind: test } - entries: - main: - agent: only - """; - - [Fact] - public async Task TheDefaultAuditSink_ReachesTheTurnLoopAndTheChainVerifies() - { - using StartedHost provider = await BuildAsync(OneAgentYaml); - - ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // The queue is what keeps the append off the turn, so the rows land on a thread of their own - // and a reader that wants them now asks for them now. - await Queue(provider).FlushAsync(TestContext.Current.CancellationToken); - - IReadOnlyList events = Sink(provider).EventsOf("conversation-1"); - Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], - events.Select(item => item.Kind).ToArray()); - Assert.All(events, AuditEventVocabulary.Validate); - } - - [Fact] - public async Task ADocumentThatNamesNoAuditProvider_StillOpensTheMemorySink() - { - using StartedHost provider = await BuildAsync(OneAgentYaml); - - // The turn loop produces the events of D23 whatever a document says, so the seam that receives - // them has a working default rather than a null. That is what lets every reading of a conversation be - // unconditional, and what lets a first run and a test work with no database. - Assert.NotNull(provider.GetService()); - _ = Assert.IsType(provider.GetRequiredService().Store); - } - - [Fact] - public async Task ADocumentThatNamesTheMemoryKind_OpensTheSameSinkAsNamingNothing() - { - using StartedHost provider = await BuildAsync(MemoryAuditYaml); - - // memory is this library's own name and it needs no registered vendor, so writing it says out - // loud what leaving the block out does quietly. The startup warning is the difference. - _ = Assert.IsType(provider.GetRequiredService().Store); - } - - [Fact] - public async Task AnAuditVendorTheDocumentNames_IsTheStoreBehindTheQueue() - { - RecordingAuditSink store = new(); - using StartedHost provider = await BuildAsync( - VendorAuditYaml, - options => options.UseAuditSinks(new TestAuditSinkAdapter(store))); - - ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - await Queue(provider).FlushAsync(TestContext.Current.CancellationToken); - - // The host lists its vendors once and providers.audit.kind picks one, exactly as the five - // seams beside it. Nothing but the document decides which store the chain lands in. - Assert.Same(store, provider.GetRequiredService().Store); - Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], - store.Events.Select(item => item.Kind).ToArray()); - } - - [Fact] - public async Task AnAuditKindThisHostDoesNotRegister_FailsTheStart() - { - // A document that asked for something this host cannot give fails while the host starts, and - // never on a conversation. The message names the kind, exactly as every other vendor seam. - ConfigurationLoadException failure = await Assert.ThrowsAsync( - () => BuildAsync(VendorAuditYaml)); - - Assert.Contains("test", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task TheAuditSink_IsWrappedInTheQueueThatKeepsItOffTheTurn() - { - using StartedHost provider = await BuildAsync(OneAgentYaml); - - // Section 7 puts a durable insert at 13 ms p50 against 91 nanoseconds to enqueue, and the rule - // that follows is applied once, here, to whatever the document opened. So an adapter that - // blocks on its database is a correct adapter, and no adapter carries a queue of its own. - _ = Assert.IsType(provider.GetRequiredService()); - } - - /// Reads back the queue the composition root put in front of the document's store. - private static QueuedAuditSink Queue(IServiceProvider provider) - { - return Assert.IsType(provider.GetRequiredService()); - } - - /// Reads back the store itself, which is registered under its own concrete type. - /// - /// The document builds the store now, not the host, so this is how a test that wants the events - /// of one conversation reaches the thing that holds them. Resolving gives the - /// queue instead, because that is the only registration that honours the port's contract. - /// - private static InMemoryAuditSink Sink(IServiceProvider provider) - { - return Assert.IsType(provider.GetRequiredService().Store); - } - - // ------------------------------------------------------------------------------------------- - // The transcript store: named by providers.conversations, and never absent. - // ------------------------------------------------------------------------------------------- // The same agent, served by a conversation-store vendor the host registers itself. private const string VendorTranscriptYaml = @@ -1312,7 +1086,7 @@ private static InMemoryAuditSink Sink(IServiceProvider provider) agent: only """; - // The conversation store opens at step 4c and the moderation vendor is built at step 4c, so a + // The conversation store opens before the moderation vendor is built, so a // document that names both puts a failure strictly after an open. Nothing else in the boot has // that shape. private const string ConversationStoreThenModerationFailureYaml = @@ -1383,13 +1157,7 @@ public ValueTask OpenAsync( } } - /// A store 1 backing that keeps the role of every row it accepted. - /// - /// Keeps only , with no conversationId or ordinal, so it cannot rebuild a - /// to answer a read or an erase truthfully. and - /// report empty and 0 even after has recorded - /// rows — a test that needs either should use a different double. - /// + /// A conversation store that keeps the role of every row it accepted. private sealed class RecordingConversationStore() : DelegatingConversationStore(new InMemoryConversationStore()), IAsyncDisposable { private readonly Lock _gate = new(); @@ -1513,70 +1281,6 @@ public async Task NoLoggerAndNoAuditVendor_StillRunsATurn() Assert.Equal("hello", turn.ReplyText); } - // ------------------------------------------------------------------------------------------- - // The host's own observers: the socket behind IConversationObserver. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task AHostObserver_ReadsTheFactsOfATurn() - { - RecordingConversationObserver first = new(); - RecordingConversationObserver second = new(); - using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.UseObservers(first, second)); - - ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // The port is public, so a host writes one of these and binds it. Every observer of a conversation - // reads the same facts, and the library's own three are neither replaced nor bypassed. - Assert.Equal([ConversationEventKind.ConversationStarted, ConversationEventKind.TurnCompleted], first.Seen); - Assert.Equal([ConversationEventKind.ConversationStarted, ConversationEventKind.TurnCompleted], second.Seen); - } - - [Fact] - public async Task AHostObserverThatThrows_CostsNeitherTheTurnNorTheChain() - { - using StartedHost provider = await BuildAsync( - OneAgentYaml, - options => options.UseObservers(new ThrowingConversationObserver())); - - ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // An observer records the conversation and is never a part of it. That holds for the host's own, and - // it holds for the readings registered beside it: a broken host observer does not cost the - // chain of D23 a single row. - Assert.Equal("hello", turn.ReplyText); - - await Queue(provider).FlushAsync(TestContext.Current.CancellationToken); - - IReadOnlyList events = Sink(provider).EventsOf("conversation-1"); - Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], - events.Select(item => item.Kind).ToArray()); - Assert.All(events, AuditEventVocabulary.Validate); - } - - [Fact] - public async Task UseObserversTwice_KeepsBothRegistrations() - { - RecordingConversationObserver first = new(); - RecordingConversationObserver second = new(); - using StartedHost provider = await BuildAsync( - OneAgentYaml, - options => options.UseObservers(first).UseObservers(second)); - - ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("conversation-1"); - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // The seam adds rather than replaces, so a host composes its readings across whatever code - // configures the container. - Assert.NotEmpty(first.Seen); - Assert.NotEmpty(second.Seen); - } - - // ------------------------------------------------------------------------------------------- - // What it refuses. - // ------------------------------------------------------------------------------------------- [Fact] public async Task ABadDocument_FailsTheStartAndNamesTheFault() { @@ -1635,9 +1339,9 @@ public async Task AToolReferenceFailureAfterDiscoverySucceeds_StillDisposesTheSo } /// - /// The conversation store is opened at step 4c, and the moderation vendor is built after it. A + /// The conversation store is opened before the moderation vendor is built. A /// document that names a moderation kind this host does not register therefore fails with the - /// store already open, and nothing between the two steps has taken ownership of it. + /// store already open, and nothing between the two has taken ownership of it. /// [Fact] public async Task AFailureAfterTheConversationStoreOpens_StillClosesTheStore() @@ -1673,8 +1377,8 @@ public async Task AnIdCollisionFoundAfterDiscovery_StillClosesTheSource() /// /// An id no tools: entry names, served only by a discovering source, still satisfies an /// agent's reference through - /// end to end, public API only. An mcp: server's tools work exactly this way: decision 15 - /// requires the reference pass to resolve against what got discovered, not just what got declared. + /// end to end, public API only. An mcp: server's tools work exactly this way: the + /// reference pass must resolve against what got discovered, not just what got declared. /// [Fact] public async Task ADiscoveredOnlyTool_SatisfiesAnAgentsReferenceThroughTheRealBoot() @@ -1692,7 +1396,7 @@ public async Task ADiscoveredOnlyTool_SatisfiesAnAgentsReferenceThroughTheRealBo /// out of its own "every declaration is served" rule, because that kind reaches no source — the /// compile table builds it once the agent it names has compiled. The reference pass in the /// composition root has to carve the same kind out of its own served-ids set for the same reason, - /// or a document exactly like this one — the shape section 8.1 calls agent-as-tool — fails to + /// or a document exactly like this one — an agent-as-tool — fails to /// boot even though it declares nothing wrong. /// [Fact] @@ -1704,7 +1408,7 @@ public async Task ADelegatingAgentTool_BootsBecauseKindAgentReachesNoSource() } /// - /// Decision 15's whole justification: a YAML typo must never cost a round trip to an MCP server. + /// A YAML typo must never cost a round trip to an MCP server. /// This document carries both a structural defect and an mcp: server whose command does /// not exist, so the two possible orderings are observably different: structure-first reports /// the policy fault and never touches the server; discovery-first would instead report that the @@ -1924,9 +1628,6 @@ public async Task AReservedSkillToolIdFailure_StillClosesTheSkillsSource() Assert.True(tracked.Disposed); } - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- /// Composes the guarded graph over one offline model for each node. /// The provider a test resolves from. private static Task BuildGuardedGraphAsync() @@ -1950,32 +1651,6 @@ private static Task BuildGuardedGraphAsync() return (builder.Build(), adapter); } - private static async Task<(IHost Host, ClosingAuditSink Store)> BuildAuditHostAsync() - { - ClosingAuditSink store = new(); - HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new()); - ConfigureServices( - builder.Services, - VendorAuditYaml, - options => options.UseAuditSinks(new TestAuditSinkAdapter(store))); - - return (builder.Build(), store); - } - - /// One well-formed event, which is all a drain has to carry. - /// Seconds past the epoch the event occurred at, so callers can order rows. - /// The event. - private static AuditEvent AuditRow(long secondsPastEpoch) - { - return new() - { - ConversationId = "conversation-1", - EventId = Guid.CreateVersion7(), - Kind = AuditEventKind.TurnCompleted, - OccurredAt = DateTimeOffset.UnixEpoch.AddSeconds(secondsPastEpoch), - }; - } - /// An adapter that starts nothing and hands back a session that records its flush. private sealed class FlushRecordingTelemetryAdapter(string kind) : ITelemetryAdapter { @@ -2006,45 +1681,6 @@ public ValueTask DisposeAsync() } } - /// An observer a host binds, which keeps every fact it was offered, in order. - private sealed class RecordingConversationObserver : IConversationObserver - { - private readonly Lock _gate = new(); - private readonly List _seen = []; - - /// Gets what this observer read, in the order the conversation produced it. - public IReadOnlyList Seen - { - get - { - // A delivery may land on a thread of its own, so the reading is taken under the gate. - lock (_gate) - { - return [.. _seen]; - } - } - } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - lock (_gate) - { - _seen.Add(conversationEvent.Kind); - } - - return ValueTask.CompletedTask; - } - } - - /// An observer that refuses every fact, so the isolation of the seam is observable. - private sealed class ThrowingConversationObserver : IConversationObserver - { - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken) - { - throw new InvalidOperationException("the host's observer is broken"); - } - } - /// A moderation vendor a test registers, which counts the times it was asked to build. private sealed class FakeModerationAdapter(string kind, IEvaluator evaluator) : IModerationAdapter { @@ -2063,87 +1699,6 @@ public ValueTask CreateAsync( } } - /// An audit vendor a test registers, which opens one store the test already holds. - private sealed class TestAuditSinkAdapter(IAuditSinkPort store) : IAuditSinkAdapter - { - public string Kind => "test"; - - public ValueTask OpenAsync( - VendorProviderConfiguration entry, - ISecretResolverPort? secrets, - CancellationToken cancellationToken = default) - { - return ValueTask.FromResult(store); - } - } - - /// An audit store that is slow to write and records what it held when it was closed. - /// - /// The delay is the whole test: a shutdown that does not wait for the queue returns long before - /// this store has been handed anything, so a lost row is a failed assertion rather than a race. - /// - private sealed class ClosingAuditSink : IAuditSinkPort, IAsyncDisposable - { - private static readonly TimeSpan WriteDelay = TimeSpan.FromMilliseconds(200); - - private int _written; - - /// Gets the number of events this store has written. - public int Written => Volatile.Read(ref _written); - - /// Gets whether this store was closed. - public bool Closed { get; private set; } - - /// Gets how many events this store had written by the time it was closed. - public int WrittenWhenClosed { get; private set; } - - public async ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) - { - await Task.Delay(WriteDelay, CancellationToken.None); - _ = Interlocked.Increment(ref _written); - } - - public ValueTask DisposeAsync() - { - Closed = true; - WrittenWhenClosed = Written; - return ValueTask.CompletedTask; - } - } - - /// An audit store that keeps what it accepted, so what the document opened is observable. - /// - /// It is not , on purpose: the test has to tell the vendor the - /// document named apart from the built-in the document would have fallen back to. - /// - private sealed class RecordingAuditSink : IAuditSinkPort - { - private readonly Lock _gate = new(); - private readonly List _events = []; - - /// Gets the events this store accepted, in the order they arrived. - public IReadOnlyList Events - { - get - { - lock (_gate) - { - return [.. _events]; - } - } - } - - public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) - { - lock (_gate) - { - _events.Add(auditEvent); - } - - return ValueTask.CompletedTask; - } - } - /// A moderation evaluator that flags every text, so the wiring is observable. private sealed class AlwaysFlagsEvaluator : IEvaluator { diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs index cbb8750c..e9c40d3c 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs @@ -1,11 +1,11 @@ using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; namespace AgentCore.AspNetCore.Tests.DependencyInjection diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/StartedHostFixture.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/StartedHostFixture.cs index ee0e0cad..2997d43c 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/StartedHostFixture.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/StartedHostFixture.cs @@ -9,11 +9,6 @@ namespace AgentCore.AspNetCore.Tests.DependencyInjection { /// Builds a host over one document, which is where the whole boot happens. - /// - /// Shared by every DI test that only needs a booted host and nothing document-specific — the - /// tests that need their own document still write their own YAML and conversation - /// or directly. - /// internal static class StartedHostFixture { /// The one-agent document every test that needs no more than that boots on. @@ -35,6 +30,24 @@ internal static class StartedHostFixture agent: only """; + /// The conversation and speech providers the test documents share. + internal const string SpeechAndConversation = + """ + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + """; + + /// plus the single reply model most documents declare. + internal const string MinimalProviders = + $$""" + {{SpeechAndConversation}} + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + """; + /// Starts a host on one document. /// The document to boot. /// The host's own word on the options. @@ -50,12 +63,6 @@ internal static async Task BuildAsync(string yaml, ActionStarts one host, and closes it itself when the start fails. /// The host to start. /// The started host. - /// - /// A failed start never stops what already started, so disposal is the only cleanup path — and - /// it is the one a real host takes too, inside RunAsync's own finally. StopAsync is - /// deliberately not called here: on net10 a host that failed to start throws - /// out of StopAsync when the failure was a constructor. - /// internal static async Task StartAsync(IHost host) { try diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs index 24065b66..ca65aab7 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs @@ -1,11 +1,11 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; +using AgentCore.Application.Runtime.Session; using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; namespace AgentCore.AspNetCore.Tests.DependencyInjection diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs new file mode 100644 index 00000000..7b1cd758 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Security.Claims; +using System.Text; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks.Gates; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Routing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; +using static AgentCore.AspNetCore.Tests.Endpoints.EntryGateFixture; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// What the entry gate sees: the route, the URL's entry, the headers, the caller and the transport. + public sealed class EntryGateFactsTests + { + private const string SocketYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: front, instructions: "answer visitors" } + providers: + conversation: { kind: probe } + speech: + stt: { kind: probe } + tts: { kind: probe } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: front + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 60_000)] + public async Task TheGateSeesTheRouteTheUrlsEntryTheHeadersAndTheTransport() + { + List seen = []; + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + new Models().Bind(options); + _ = options.UseHooks(new Entry(seen.Add)); + }, + map: app => + { + // HttpClient folds repeated values into one "a, b" line, so the second value is set server-side. + _ = app.Use((http, next) => + { + http.Request.Headers["X-Caller"] = new StringValues(["a", "b"]); + return next(http); + }); + _ = app.MapResponses("/v1/{entry}/responses"); + }); + + using HttpRequestMessage request = new(HttpMethod.Post, "/v1/staff/responses") + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + using HttpResponseMessage response = await host.Client.SendAsync(request, Ct); + + EntryGate gate = Assert.Single(seen); + Assert.Equal(("/v1/{entry}/responses", "staff", EntryTransport.Http), (gate.Route, gate.RequestedEntry, gate.Transport)); + Assert.Equal("a,b", gate.Headers["X-Caller"]); + Assert.Null(gate.User); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 60_000)] + public async Task AnAuthenticatedCallersPrincipalReachesTheGateWithItsClaims() + { + List seen = []; + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + new Models().Bind(options); + _ = options.UseHooks(new Entry(seen.Add)); + }, + map: app => + { + _ = app.Use((http, next) => + { + http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "ada")], authenticationType: "test")); + return next(http); + }); + _ = app.MapResponses("/v1/{entry}/responses"); + }); + + using HttpRequestMessage request = new(HttpMethod.Post, "/v1/staff/responses") + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + using HttpResponseMessage response = await host.Client.SendAsync(request, Ct); + + EntryGate gate = Assert.Single(seen); + Assert.Equal("ada", gate.User?.Identity?.Name); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 60_000)] + public async Task TheGatesHeadersCannotBeChanged() + { + Exception? thrown = null; + Models models = new(); + await using ResponsesHost host = await StartAsync( + models, + new Entry(gate => thrown = Record.Exception(() => ((IDictionary)gate.Headers)["X-Added"] = "1"))); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.IsType(thrown); + } + + [Fact(Timeout = 60_000)] + public async Task TheConversationRouteRefusesBeforeItsHandlerRunsAndSaysItIsACall() + { + HandlerProbe transport = new(); + List transports = []; + await using ResponsesHost host = await ResponsesHost.StartAsync( + SocketYaml, + new FragmentingChatClient("unused"), + options => + { + _ = options.UseConversation(transport); + _ = options.UseSpeech(new SilentSpeech()); + _ = options.UseHooks(new Entry(gate => { transports.Add(gate.Transport); gate.Refuse(); })); + }, + map: app => app.MapCall("/v1/call")); + + using HttpResponseMessage response = await host.Client.GetAsync(new Uri("/v1/call", UriKind.Relative), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.False(transport.Ran); + Assert.Equal([EntryTransport.Call], transports); + } + + /// A transport whose handler records that it ran. The handler is where a socket upgrade happens. + private sealed class HandlerProbe : IConversationTransportAdapter + { + public string Kind => "probe"; + + public bool CarriesText => true; + + public bool Ran { get; private set; } + + public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + { + return _ => + { + Ran = true; + return Task.CompletedTask; + }; + } + } + + /// The speech vendor the pairing rule reads, which builds nothing. + private sealed class SilentSpeech : ISpeechAdapter + { + public string Kind => "probe"; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFixture.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFixture.cs new file mode 100644 index 00000000..d143a09b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFixture.cs @@ -0,0 +1,91 @@ +using System.Text; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// The two-entry host the entry gate tests share: a model per agent and a hook that decides inline. + internal static class EntryGateFixture + { + internal const string Route = "/v1/chat/responses"; + + internal const string TwoEntryYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: front, instructions: "answer visitors", model: { ref: front } } + - { id: desk, instructions: "answer staff", model: { ref: desk } } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: front } + - { kind: openai, model: gpt-4.1-mini, as: desk } + entries: + main: + agent: front + staff: + agent: desk + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + internal static Task StartAsync(Models models, Entry hook) + { + return ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + models.Bind(options); + _ = options.UseHooks(hook); + }, + map: app => app.MapResponses(Route)); + } + + internal static Task PostAsync(ResponsesHost host) + { + HttpRequestMessage request = new(HttpMethod.Post, Route) + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + + return host.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); + } + + /// One model per agent, so a test reads which agent answered. + internal sealed class Models + { + public FragmentingChatClient Front { get; } = new("front reply"); + + public FragmentingChatClient Desk { get; } = new("desk reply"); + + public void Bind(AgentCoreOptions options) + { + _ = options.UseChatClients(_ => new RoutingChatClientFactory().Route("front", Front).Route("desk", Desk)); + } + } + + internal sealed class Entry(Action decide) : AgentHook + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public override ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) + { + _ = Interlocked.Increment(ref _calls); + decide(gate); + return default; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateTests.cs new file mode 100644 index 00000000..ca05b5e2 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using System.Text; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.AspNetCore.Builder; +using Xunit; +using static AgentCore.AspNetCore.Tests.Endpoints.EntryGateFixture; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// The entry gate picks the entry a request runs; the URL's entry is the default. + public sealed class EntryGateTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 60_000)] + public async Task TheHooksEntryAnswers() + { + Models models = new(); + await using ResponsesHost host = await StartAsync(models, new Entry(gate => gate.Choose("staff"))); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("desk reply", (await ResponsesHost.ReadJsonAsync(response)).OutputText(), StringComparison.Ordinal); + Assert.Equal(0, models.Front.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task ARefusingHookAnswers403AndRunsNoAgent() + { + Models models = new(); + await using ResponsesHost host = await StartAsync(models, new Entry(gate => gate.Refuse())); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal("entry_refused", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); + Assert.Equal(0, models.Front.Calls + models.Desk.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task AHookThatNamesAnUndeclaredEntryAnswers404() + { + await using ResponsesHost host = await StartAsync(new Models(), new Entry(gate => gate.Choose("nobody"))); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown_entry", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); + } + + [Fact(Timeout = 60_000)] + public async Task WithNoHookTheUrlsEntryRuns() + { + Models models = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + models.Bind, + map: app => app.MapResponses("/v1/{entry}/responses")); + + using HttpRequestMessage request = new(HttpMethod.Post, "/v1/staff/responses") + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + using HttpResponseMessage response = await host.Client.SendAsync(request, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("desk reply", (await ResponsesHost.ReadJsonAsync(response)).OutputText(), StringComparison.Ordinal); + } + + [Fact(Timeout = 60_000)] + public async Task AHookThatDoesNotDecideTakesTheUrlsEntry() + { + Models models = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + models.Bind(options); + _ = options.UseHooks(new Entry(_ => { })); + }, + map: app => app.MapResponses("/v1/{entry}/responses")); + + using HttpRequestMessage request = new(HttpMethod.Post, "/v1/staff/responses") + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + using HttpResponseMessage response = await host.Client.SendAsync(request, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("desk reply", (await ResponsesHost.ReadJsonAsync(response)).OutputText(), StringComparison.Ordinal); + } + + [Fact(Timeout = 60_000)] + public async Task ARouteWithoutAnEntrySegmentRefusesWhenTheHookDoesNotDecide() + { + Models models = new(); + await using ResponsesHost host = await StartAsync(models, new Entry(_ => { })); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal("entry_refused", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); + Assert.Equal(0, models.Front.Calls + models.Desk.Calls); + } + + // A failing gate refuses the request. + [Fact(Timeout = 60_000)] + public async Task AHookThatThrowsIsRefusedEvenWhenTheUrlNamesAnEntry() + { + Models models = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + models.Bind(options); + _ = options.UseHooks(new Entry(_ => throw new InvalidOperationException("auth down"))); + }, + map: app => app.MapResponses("/v1/{entry}/responses")); + + using HttpRequestMessage request = new(HttpMethod.Post, "/v1/staff/responses") + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + using HttpResponseMessage response = await host.Client.SendAsync(request, Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(0, models.Front.Calls + models.Desk.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task ARefusalIsKeptSoTheGateRunsOnceWhenMiddlewareResolvesFirst() + { + Entry hook = new(gate => gate.Refuse()); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + new Models().Bind(options); + _ = options.UseHooks(hook); + }, + map: app => + { + _ = app.Use(async (http, next) => + { + _ = await AgentCoreEntries.ResolveAsync(http, http.RequestAborted); + await next(http); + }); + _ = app.MapResponses(Route); + }); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(1, hook.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task MiddlewareThatResolvesFirstLeavesTheGateRunOnce() + { + Entry hook = new(gate => gate.Choose("staff")); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + options => + { + new Models().Bind(options); + _ = options.UseHooks(hook); + }, + map: app => + { + _ = app.Use(async (http, next) => + { + _ = await AgentCoreEntries.ResolveAsync(http, http.RequestAborted); + await next(http); + }); + _ = app.MapResponses(Route); + }); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, hook.Calls); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs deleted file mode 100644 index cadc98e4..00000000 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System.Net; -using System.Text; -using AgentCore.Application.Configuration.Schema; -using AgentCore.AspNetCore.Endpoints; -using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; -using AgentCore.TestSupport; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace AgentCore.AspNetCore.Tests.Endpoints -{ - /// - /// A route with an runs the entry the server picks, not one the URL names - /// (entry selector spec, section 4). - /// - public sealed class EntrySelectorTests - { - private const string Route = "/v1/chat/responses"; - - private const string TwoEntryYaml = - """ - apiVersion: agentcore/v1 - agents: - items: - - { id: front, instructions: "answer visitors", model: { ref: front } } - - { id: desk, instructions: "answer staff", model: { ref: desk } } - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - llm: - - { kind: openai, model: gpt-4.1-mini, as: front } - - { kind: openai, model: gpt-4.1-mini, as: desk } - entries: - main: - agent: front - staff: - agent: desk - """; - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact(Timeout = 60_000)] - public async Task TheSelectorsEntryAnswers() - { - Models models = new(); - await using ResponsesHost host = await StartAsync(models, "staff"); - - using HttpResponseMessage response = await PostAsync(host); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains("desk reply", (await ResponsesHost.ReadJsonAsync(response)).OutputText(), StringComparison.Ordinal); - Assert.Equal(0, models.Front.Calls); - } - - [Fact(Timeout = 60_000)] - public async Task ARefusingSelectorAnswers403AndRunsNoAgent() - { - Models models = new(); - await using ResponsesHost host = await StartAsync(models, entry: null); - - using HttpResponseMessage response = await PostAsync(host); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - Assert.Equal("entry_refused", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); - Assert.Equal(0, models.Front.Calls + models.Desk.Calls); - } - - [Fact(Timeout = 60_000)] - public async Task ASelectorThatNamesAnUndeclaredEntryAnswers404() - { - await using ResponsesHost host = await StartAsync(new Models(), "nobody"); - - using HttpResponseMessage response = await PostAsync(host); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal("unknown_entry", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); - } - - [Fact(Timeout = 60_000)] - public async Task MiddlewareThatResolvesFirstLeavesTheSelectorRunOnce() - { - FixedSelector selector = new("staff"); - await using ResponsesHost host = await ResponsesHost.StartAsync( - TwoEntryYaml, - new FragmentingChatClient("unused"), - new Models().Bind, - services => services.AddSingleton(selector), - app => - { - _ = app.Use(async (http, next) => - { - _ = await AgentCoreEntries.ResolveAsync(http, http.RequestAborted); - await next(http); - }); - _ = app.MapResponses(Route).SelectEntry(); - }); - - using HttpResponseMessage response = await PostAsync(host); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(1, selector.Calls); - } - - [Fact(Timeout = 60_000)] - public async Task TheConversationRouteRefusesBeforeItsHandlerRuns() - { - HandlerProbe transport = new(); - await using ResponsesHost host = await ResponsesHost.StartAsync( - SocketYaml, - new FragmentingChatClient("unused"), - options => - { - _ = options.UseConversation(transport); - _ = options.UseSpeech(new SilentSpeech()); - }, - services => services.AddSingleton(new FixedSelector(null)), - app => app.MapCall("/v1/call").SelectEntry()); - - using HttpResponseMessage response = await host.Client.GetAsync(new Uri("/v1/call", UriKind.Relative), Ct); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - Assert.False(transport.Ran); - } - - private const string SocketYaml = - """ - apiVersion: agentcore/v1 - agents: - items: - - { id: front, instructions: "answer visitors" } - providers: - conversation: { kind: probe } - speech: - stt: { kind: probe } - tts: { kind: probe } - llm: - - { kind: openai, model: gpt-4.1-mini, as: reply } - entries: - main: - agent: front - """; - - private static Task StartAsync(Models models, string? entry) - { - return ResponsesHost.StartAsync( - TwoEntryYaml, - new FragmentingChatClient("unused"), - models.Bind, - services => services.AddSingleton(new FixedSelector(entry)), - app => app.MapResponses(Route).SelectEntry()); - } - - private static Task PostAsync(ResponsesHost host) - { - HttpRequestMessage request = new(HttpMethod.Post, Route) - { - Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), - }; - - return host.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); - } - - /// One model per agent, so a test reads which agent answered. - private sealed class Models - { - public FragmentingChatClient Front { get; } = new("front reply"); - - public FragmentingChatClient Desk { get; } = new("desk reply"); - - public void Bind(AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions options) - { - _ = options.UseChatClients(_ => new RoutingChatClientFactory().Route("front", Front).Route("desk", Desk)); - } - } - - /// Answers one entry for every caller, and counts how often it was asked. - private sealed class FixedSelector(string? entry) : IEntrySelector - { - private int _calls; - - public int Calls => Volatile.Read(ref _calls); - - public ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken) - { - _ = Interlocked.Increment(ref _calls); - return ValueTask.FromResult(entry); - } - } - - /// A transport whose handler records that it ran. The handler is where a socket upgrade happens. - private sealed class HandlerProbe : IConversationTransportAdapter - { - public string Kind => "probe"; - - public bool CarriesText => true; - - public bool Ran { get; private set; } - - public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) - { - return _ => - { - Ran = true; - return Task.CompletedTask; - }; - } - } - - /// The speech vendor the pairing rule reads, which builds nothing. - private sealed class SilentSpeech : ISpeechAdapter - { - public string Kind => "probe"; - } - } -} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs index b55e603e..bc1b1ae2 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs @@ -2,7 +2,6 @@ using System.Text; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.Fakes; @@ -10,12 +9,13 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Endpoints { /// /// The HTTP Responses path runs every turn of a live conversation on the one session the owner holds, so - /// consecutive turns share one shell and read the transcript at most once per load (probe P1, issue #24). + /// consecutive turns share one shell and read the transcript at most once per load (issue #24). /// An idle unload ends that session: the next turn gets a new shell and an empty workspace folder, and /// reads the history back from the store. /// @@ -140,7 +140,7 @@ public async Task AThirdTurnElevenMinutesLater_GetsANewShellWithHistoryIntactAnd using HttpResponseMessage second = await PostAsync(host.Client, conversation, "go again"); Assert.Equal(HttpStatusCode.OK, second.StatusCode); - // Past the 10-minute default idle timeout: the live session unloads (no words lost, store 0 + // Past the 10-minute default idle timeout: the live session unloads (no words lost, the store // still holds them), its shell stops, and its workspace folder is deleted. clock.Advance(TimeSpan.FromMinutes(11)); diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs index 02750309..08bac775 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs @@ -85,7 +85,7 @@ public sealed class ResponseContinuationTests // Promoted from the response-continuation probe (Q3): an old previous_response_id is not a // branch (D2). It runs at the conversation's current turn index and sees the current todos, live - // and after the session has unloaded and rebuilt from store 0. + // and after the session has unloaded and rebuilt from the conversation store. [Theory(Timeout = 60_000)] [InlineData(false)] [InlineData(true)] diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs index 58374a33..25f53a30 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs @@ -1,7 +1,10 @@ using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; @@ -76,6 +79,35 @@ public async Task ApprovalAnswer_RunsTheToolAndReplies() Assert.Contains("done.", replied.OutputText(), StringComparison.Ordinal); } + // The answer is raised when the turn takes it in, under the function call id the gated fake gives its call. + [Fact] + public async Task ApprovalAnswer_IsRaisedOnceAsAHumansApproval() + { + RecordingHook hook = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + ApprovalYaml, + new GatedToolCallingChatClient(), + configure: options => + { + options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { }))); + _ = options.UseHooks(hook); + }); + + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); + JsonNode asked = await ResponsesHost.ReadJsonAsync(first); + string? requestId = JsonDocument.Parse( + asked["metadata"]!["approvals"]!.GetValue()) + .RootElement[0].GetProperty("request_id").GetString(); + using HttpResponseMessage second = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{asked.ContinuationId()}}", "input": [], "agentcore": { "approval": { "request_id": "{{requestId}}", "approved": true } } }"""); + _ = await hook.WaitForAsync(completed => completed.Scope.TurnIndex == 1).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal( + [(ApprovalState.Asked, ApprovalBy.Human, "conversation_1"), (ApprovalState.Approved, ApprovalBy.Human, "conversation_1")], + hook.Of().Select(changed => (changed.State, changed.By, changed.CallId))); + } + [Fact] public async Task ApprovalAnswerForARequestNothingAsked_IsConflict() { diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs index 83813b16..eb408c85 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Audit; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.Endpoints; using AgentCore.AspNetCore.Tests.Fakes; @@ -13,12 +12,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Endpoints { /// /// A request on a conversation another request still holds waits for the busy mark, then reads the conversation - /// afresh (owner ruling for soak F1). A client that sends one message at a time is never refused; a wait past the + /// afresh. A client that sends one message at a time is never refused; a wait past the /// limit is. /// public sealed class ResponsesBusyConversationTests @@ -46,18 +46,17 @@ public sealed class ResponsesBusyConversationTests private static CancellationToken Ct => TestContext.Current.CancellationToken; - // Soak F1: a stopped turn files its words after the abort. A next message that read the conversation before + // A stopped turn files its words after the abort. A next message that read the conversation before // they landed would take the same turn index, and the store would keep only one of the two. [Fact(Timeout = 60_000)] public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnce_KeepsBothTurnsInOrder() { - // Arrange TurnHoldingStore store = new(heldTurn: 1); await using ResponsesHost host = await ResponsesHost.StartAsync( Yaml, new StallOnCueChatClient(), options => options.UseConversationStores(store)); string conversation = await OpenAsync(host); - // Act: the client reads the first piece of a streamed reply and drops the connection, as a browser does. + // The client reads the first piece of a streamed reply and drops the connection, as a browser does. using (HttpClient browser = new(new SocketsHttpHandler { MaxResponseDrainSize = 0 }) { BaseAddress = host.Client.BaseAddress }) { using HttpResponseMessage stopped = await PostAsync(browser, conversation, "stall now", stream: true); @@ -72,7 +71,6 @@ public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnce_KeepsBothTurn store.Release(); using HttpResponseMessage next = await sending; - // Assert await AssertBothTurnsKeptInOrderAsync(host, conversation, next); } @@ -81,14 +79,12 @@ public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnce_KeepsBothTurn [Fact(Timeout = 60_000)] public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnceOnAnotherHost_KeepsBothTurnsInOrder() { - // Arrange TurnHoldingStore store = new(heldTurn: 1); StallOnCueChatClient model = new(); await using ResponsesHost host = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); await using ResponsesHost other = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); string conversation = await OpenAsync(host); - // Act using (HttpClient browser = new(new SocketsHttpHandler { MaxResponseDrainSize = 0 }) { BaseAddress = host.Client.BaseAddress }) { using HttpResponseMessage stopped = await PostAsync(browser, conversation, "stall now", stream: true); @@ -101,14 +97,12 @@ public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnceOnAnotherHost_ store.Release(); using HttpResponseMessage next = await sending; - // Assert await AssertBothTurnsKeptInOrderAsync(other, conversation, next); } [Fact(Timeout = 60_000)] public async Task ATurnStillRunningOnThisHostPastTheWaitLimit_Answers409_AndLeavesATurnRefused() { - // Arrange FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); await using ResponsesHost host = await ResponsesHost.StartAsync( Yaml, @@ -122,13 +116,11 @@ public async Task ATurnStillRunningOnThisHostPastTheWaitLimit_Answers409_AndLeav using HttpResponseMessage running = await PostAsync(host.Client, conversation, "stall now", stream: true); await ReadUntilAsync(running, StallOnCueChatClient.Piece); - // Act Task sending = PostAsync(host.Client, conversation, "next", stream: false); _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.WaitLimit, 1), sending); time.Advance(ConversationBusyMark.WaitLimit); using HttpResponseMessage refused = await sending; - // Assert Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode); JsonNode error = (await ResponsesHost.ReadJsonAsync(refused))["error"]!; Assert.Equal(ResponsesTurnConflict.Code, error["code"]!.GetValue()); @@ -144,7 +136,6 @@ public async Task ATurnStillRunningOnThisHostPastTheWaitLimit_Answers409_AndLeav [Fact(Timeout = 60_000)] public async Task AConversationHeldPastTheWaitLimit_Answers409_AndLeavesAWarningAndATurnRefused() { - // Arrange FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); using RecordingLoggerFactory logs = new(); await using ResponsesHost host = await ResponsesHost.StartAsync( @@ -160,13 +151,11 @@ public async Task AConversationHeldPastTheWaitLimit_Answers409_AndLeavesAWarning IConversationStore store = host.Services.GetRequiredService(); _ = await store.TryMarkBusyAsync(conversation, "other-host", TimeSpan.FromHours(1), Ct); - // Act Task sending = PostAsync(host.Client, conversation, "next", stream: false); _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.FirstPoll, 1), sending); time.Advance(ConversationBusyMark.WaitLimit); using HttpResponseMessage refused = await sending; - // Assert Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode); JsonNode error = (await ResponsesHost.ReadJsonAsync(refused))["error"]!; Assert.Equal(ResponsesTurnConflict.Code, error["code"]!.GetValue()); diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs index 2c13e359..c34e717e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs @@ -12,7 +12,7 @@ namespace AgentCore.AspNetCore.Tests.Endpoints { /// - /// A turn whose store 1 append failed once is still filed under its response id: no other session holds that id, + /// A turn whose message store append failed once is still filed under its response id: no other session holds that id, /// and the next turn reads the store back before it runs. Only a turn the store refused is never filed /// (). /// diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs index 21830a70..a315363e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs @@ -15,12 +15,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.AspNetCore.Tests.Endpoints { /// /// Filing after the client aborts runs on no host token, so it needs a bound of its own: the one the - /// rest of the work after the reply runs under (design section 7, item 4). + /// rest of the work after the reply runs under. /// public sealed class ResponsesFilingAfterAbortTests { @@ -36,7 +38,7 @@ public async Task AFilingThatNeverEndsIsStoppedByTheBoundAndLogged() Task filing = ResponsesTurnStream.FileAfterAbortAsync( _ => never.Task, "conv-f5", time, logs.CreateLogger("test")); - time.Advance(ConversationSession.TurnCompletionTimeout - TimeSpan.FromTicks(1)); + time.Advance(TurnFailureReasons.CompletionTimeout - TimeSpan.FromTicks(1)); Assert.False(filing.IsCompleted); time.Advance(TimeSpan.FromTicks(1)); @@ -47,7 +49,7 @@ public async Task AFilingThatNeverEndsIsStoppedByTheBoundAndLogged() Assert.Equal("conv-f5", line.Field("ConversationId")); } - // Design section 3: the turn commits even when the host cancels mid-stream, so the session is still + // The turn commits even when the host cancels mid-stream, so the session is still // filed, on a token that outlives the abort. A store that honours the aborted token would refuse it. [Fact] public async Task AStreamTheClientAborted_StillFilesTheSession() diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs new file mode 100644 index 00000000..72d747b0 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs @@ -0,0 +1,281 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Tools.Registry; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// One model round that calls two approval-required tools at once: the caller sees both requests, + /// answers them one by one, and each tool runs only on its own approval. + /// + public sealed class ResponsesParallelApprovalTests + { + /// What Microsoft.Extensions.AI tells the model about a call a person refused. + private const string Rejected = "Tool call invocation rejected."; + + /// The reason a request carries when the caller sent words instead of an answer. + private const string MovedOnReason = "the user moved on without answering."; + + private const string TwoToolsYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "send both", tools: [ send_email, send_sms ] } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: greeter + """; + + private static readonly string AutoRuleYaml = TwoToolsYaml.Replace( + "tools: [ send_email, send_sms ] }", "tools: [ send_email, send_sms ], approval: { auto: [ nothing_matches ] } }", StringComparison.Ordinal); + + [Fact] + public async Task TwoGatedCallsInOneRound_AreBothAskedAndAnsweredOneByOne() + { + Dictionary ran = Ran(); + await using ResponsesHost host = await StartAsync(TwoToolsYaml, ran); + + (string conversation, Dictionary requests) = await AskAsync(host); + Assert.Equal(["send_email", "send_sms"], requests.Keys.Order(StringComparer.Ordinal)); + + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, requests["send_email"], approved: true); + JsonNode stillAsking = await ResponsesHost.ReadJsonAsync(second); + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(string.Empty, stillAsking.OutputText()); + Assert.Equal(["send_sms"], RequestIdsByTool(stillAsking).Keys); + Assert.Equal(0, ran["send_email"] + ran["send_sms"]); + + using HttpResponseMessage third = await PostApprovalAsync(host, conversation, requests["send_sms"], approved: false); + JsonNode replied = await ResponsesHost.ReadJsonAsync(third); + + Assert.Equal(HttpStatusCode.OK, third.StatusCode); + Assert.Equal(1, ran["send_email"]); + Assert.Equal(0, ran["send_sms"]); + Assert.Equal(["sent:send_email", Rejected], ToldTheModel(replied)); + } + + [Fact] + public async Task AHeldDenial_IsRaisedOnceAndBlocksItsToolOnTheFinalTurn() + { + RecordingHook hook = new(); + await using ResponsesHost host = await StartAsync(TwoToolsYaml, Ran(), hook); + + (string conversation, Dictionary requests) = await AskAsync(host); + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, requests["send_email"], approved: false); + using HttpResponseMessage third = await PostApprovalAsync(host, conversation, requests["send_sms"], approved: true); + _ = await hook.WaitForAsync(completed => completed.Scope.TurnIndex == 2) + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Equal( + [ + (ApprovalState.Asked, "send_email"), (ApprovalState.Asked, "send_sms"), + (ApprovalState.Denied, "send_email"), (ApprovalState.Approved, "send_sms"), + ], + hook.Of().Select(changed => (changed.State, changed.ToolName))); + Assert.Equal( + [("send_email", ToolOutcome.Blocked), ("send_sms", ToolOutcome.Ok)], + hook.Of().Select(called => (called.ToolName, called.Outcome)).Order()); + } + + // The given approval still counts; the open request is refused for the caller, and the words run in the same request. + [Fact] + public async Task NewWordsInsteadOfTheLastAnswer_RefuseItAndReplyToTheWords() + { + RecordingHook hook = new(); + Dictionary ran = Ran(); + await using ResponsesHost host = await StartAsync(TwoToolsYaml, ran, hook); + + (string conversation, Dictionary requests) = await AskAsync(host); + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, requests["send_email"], approved: true); + using HttpResponseMessage words = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "never mind" }"""); + JsonNode replied = await ResponsesHost.ReadJsonAsync(words); + _ = await hook.WaitForAsync(completed => completed.Scope.TurnIndex == 2) + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, words.StatusCode); + Assert.Equal(["sent:send_email", Rejected + " " + MovedOnReason], ToldTheModel(replied)); + Assert.Equal(1, ran["send_email"]); + Assert.Equal(0, ran["send_sms"]); + Assert.Equal( + [ + (ApprovalState.Asked, ApprovalBy.Human, "send_email", null), (ApprovalState.Asked, ApprovalBy.Human, "send_sms", null), + (ApprovalState.Approved, ApprovalBy.Human, "send_email", null), (ApprovalState.Denied, ApprovalBy.Human, "send_sms", MovedOnReason), + ], + hook.Of().Select(changed => (changed.State, changed.By, changed.ToolName, changed.Reason))); + Assert.Equal( + [("send_email", ToolOutcome.Ok), ("send_sms", ToolOutcome.Blocked)], + hook.Of().Select(called => (called.ToolName, called.Outcome)).Order()); + } + + [Fact] + public async Task AnAnswerAlreadyHeld_IsConflict() + { + await using ResponsesHost host = await StartAsync(TwoToolsYaml, Ran()); + + (string conversation, Dictionary requests) = await AskAsync(host); + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, requests["send_email"], approved: true); + using HttpResponseMessage again = await PostApprovalAsync(host, conversation, requests["send_email"], approved: false); + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + } + + [Fact] + public async Task TheStoredConversation_KeepsEachRequestAndAnswerOnce_AndRunsALaterTurn() + { + await using ResponsesHost host = await StartAsync(TwoToolsYaml, Ran()); + + (string conversation, Dictionary requests) = await AskAsync(host); + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, requests["send_email"], approved: true); + using HttpResponseMessage third = await PostApprovalAsync(host, conversation, requests["send_sms"], approved: true); + using HttpResponseMessage later = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "thanks" }"""); + JsonNode thanked = await ResponsesHost.ReadJsonAsync(later); + + Assert.Equal(["sent:send_email", "sent:send_sms"], ToldTheModel(thanked)); + List stored = [.. (await StoredRows.SettleAsync(host.Services, conversation, atLeast: 6)) + .SelectMany(row => row.Content.Contents)]; + Assert.Equal( + requests.Values.Order(StringComparer.Ordinal), + stored.OfType().Select(request => request.RequestId).Order(StringComparer.Ordinal)); + Assert.Equal( + requests.Values.Order(StringComparer.Ordinal), + stored.OfType().Select(answer => answer.RequestId).Order(StringComparer.Ordinal)); + } + + // With an auto: block MAF's approval layer shows the requests one at a time; the second one is shown again + // from its queue and must not be stored a second time. + [Fact] + public async Task UnderAnAutoRule_TwoGatedCallsAreAskedInTurnAndReply() + { + Dictionary ran = Ran(); + await using ResponsesHost host = await StartAsync( + AutoRuleYaml, + ran); + + (string conversation, Dictionary first) = await AskAsync(host); + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, Assert.Single(first).Value, approved: true); + KeyValuePair next = Assert.Single(RequestIdsByTool(await ResponsesHost.ReadJsonAsync(second))); + using HttpResponseMessage third = await PostApprovalAsync(host, conversation, next.Value, approved: true); + JsonNode replied = await ResponsesHost.ReadJsonAsync(third); + + Assert.NotEqual(first.Single().Key, next.Key); + Assert.Equal(1, ran["send_email"]); + Assert.Equal(1, ran["send_sms"]); + Assert.Equal(["sent:send_email", "sent:send_sms"], ToldTheModel(replied)); + } + + // MAF's approval layer keeps the first answer and sends it on with the last, so the refusal reaches the agent + // only on the final turn and must still read as Blocked, not as an undeclared tool. + [Fact] + public async Task UnderAnAutoRule_ARefusalAnsweredFirstIsBlockedOnTheFinalTurn() + { + RecordingHook hook = new(); + await using ResponsesHost host = await StartAsync(AutoRuleYaml, Ran(), hook); + + (string conversation, Dictionary first) = await AskAsync(host); + using HttpResponseMessage second = await PostApprovalAsync(host, conversation, Assert.Single(first).Value, approved: false); + KeyValuePair next = Assert.Single(RequestIdsByTool(await ResponsesHost.ReadJsonAsync(second))); + using HttpResponseMessage third = await PostApprovalAsync(host, conversation, next.Value, approved: true); + _ = await hook.WaitForAsync(completed => completed.Scope.TurnIndex == 2) + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Equal( + new[] { (first.Single().Key, ToolOutcome.Blocked), (next.Key, ToolOutcome.Ok) }.Order(), + hook.Of().Select(called => (called.ToolName, called.Outcome)).Order()); + } + + private static Dictionary Ran() + { + return new(StringComparer.Ordinal) { ["send_email"] = 0, ["send_sms"] = 0 }; + } + + private static Task StartAsync(string yaml, Dictionary ran, AgentHook? hook = null) + { + return ResponsesHost.StartAsync( + yaml, + new TwoGatedCallsChatClient(), + configure: options => + { + options.AddToolSource(_ => new TwoGatedSource(name => ran[name]++)); + if (hook is not null) + { + _ = options.UseHooks(hook); + } + }); + } + + private static async Task<(string Conversation, Dictionary Requests)> AskAsync(ResponsesHost host) + { + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send both" }"""); + JsonNode asked = await ResponsesHost.ReadJsonAsync(first); + return (asked.ContinuationId(), RequestIdsByTool(asked)); + } + + /// What the fake model said each call returned, in call order. + private static string[] ToldTheModel(JsonNode replied) + { + return [.. replied.OutputText().Split('\n').Select(line => line[(line.IndexOf('=', StringComparison.Ordinal) + 1)..])]; + } + + private static Dictionary RequestIdsByTool(JsonNode body) + { + return body["metadata"]?["approvals"]?.GetValue() is { } approvals + ? JsonDocument.Parse(approvals).RootElement.EnumerateArray().ToDictionary( + approval => approval.GetProperty("tool").GetString()!, + approval => approval.GetProperty("request_id").GetString()!, + StringComparer.Ordinal) + : []; + } + + private static Task PostApprovalAsync(ResponsesHost host, string conversation, string requestId, bool approved) + { + return host.PostAsync( + $$"""{ "stream": false, "conversation": "{{conversation}}", "input": [], "agentcore": { "approval": { "request_id": "{{requestId}}", "approved": {{(approved ? "true" : "false")}} } } }"""); + } + + /// Serves two approval-required tools; each returns sent:name when it runs. + private sealed class TwoGatedSource(Action onRun) : IToolSource + { + public ValueTask> ProvideAsync( + ToolSourceContext context, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>( + [Gated("send_email", "Send an email."), Gated("send_sms", "Send a text message.")]); + } + + private ToolRegistration Gated(string name, string description) + { + return new ToolRegistration(name, description, () => new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + (string to) => + { + onRun(name); + return $"sent:{name}"; + }, + name, + description))); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs index c5b5fc2b..18a9bd01 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs @@ -2,13 +2,13 @@ using System.Text; using System.Text.Json.Nodes; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.Fakes; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Endpoints { @@ -46,7 +46,6 @@ public sealed class ResponsesSharedSessionWaitTests [Fact(Timeout = 60_000)] public async Task AMessageSentWhileATurnStartedAnotherWayRuns_WaitsForIt_ThenRunsAsTheNextTurn() { - // Arrange await using ResponsesHost host = await StartAsync(); string conversation = await OpenAsync(host); IConversationSessions sessions = host.Services.GetRequiredService().Sessions; @@ -58,14 +57,12 @@ public async Task AMessageSentWhileATurnStartedAnotherWayRuns_WaitsForIt_ThenRun .GetAsyncEnumerator(stop.Token); Assert.True(await other.MoveNextAsync()); - // Act Task sending = PostAsync(host.Client, conversation, "next", stream: false); _ = await Task.WhenAny(sending, Task.Delay(TimeSpan.FromSeconds(1), Ct)); await stop.CancelAsync(); await DrainAsync(other); using HttpResponseMessage next = await sending; - // Assert Assert.Equal(HttpStatusCode.OK, next.StatusCode); JsonNode answer = await ResponsesHost.ReadJsonAsync(next); Assert.Equal("2", answer["metadata"]!["turn_index"]!.GetValue()); @@ -78,7 +75,6 @@ public async Task AMessageSentWhileATurnStartedAnotherWayRuns_WaitsForIt_ThenRun [Fact(Timeout = 60_000)] public async Task AMessageWaitingOnARequest_WhenTheSessionIsClosedMeanwhile_RunsOnTheReopenedConversation() { - // Arrange await using ResponsesHost host = await StartAsync(); string conversation = await OpenAsync(host); IConversationSessions sessions = host.Services.GetRequiredService().Sessions; @@ -86,7 +82,6 @@ public async Task AMessageWaitingOnARequest_WhenTheSessionIsClosedMeanwhile_Runs HttpResponseMessage stalled = await PostAsync(browser, conversation, "stall now", stream: true); await ReadUntilAsync(stalled, StallOnCueChatClient.Piece); - // Act Task sending = PostAsync(host.Client, conversation, "next", stream: false); _ = await Task.WhenAny(sending, Task.Delay(TimeSpan.FromMilliseconds(300), Ct)); Task closing = sessions.CloseAsync("main", conversation, Ct).AsTask(); @@ -95,7 +90,6 @@ public async Task AMessageWaitingOnARequest_WhenTheSessionIsClosedMeanwhile_Runs await closing; using HttpResponseMessage next = await sending; - // Assert Assert.Equal(HttpStatusCode.OK, next.StatusCode); JsonNode answer = await ResponsesHost.ReadJsonAsync(next); Assert.Equal("2", answer["metadata"]!["turn_index"]!.GetValue()); diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs index fa295919..1179cbe8 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs @@ -6,7 +6,6 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.Endpoints; using AgentCore.AspNetCore.Tests.Fakes; @@ -14,13 +13,13 @@ using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.AspNetCore.Tests.Endpoints { /// - /// A store that answers a turn's append later than . The - /// turn publishes only what the store's answer says, however late it comes (owner ruling 2026-09-24, "refuse at - /// save"). + /// A store that answers a turn's append later than . The + /// turn publishes only what the store's answer says, however late it comes. /// public sealed class ResponsesSlowStoreTests { @@ -45,14 +44,13 @@ public sealed class ResponsesSlowStoreTests agent: greeter """; - private static readonly TimeSpan PastTheBound = ConversationSession.TurnCompletionTimeout + TimeSpan.FromSeconds(1); + private static readonly TimeSpan PastTheBound = TurnFailureReasons.CompletionTimeout + TimeSpan.FromSeconds(1); private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact(Timeout = 60_000)] public async Task ARefusalThatComesAfterTheBound_FilesNothing_AndPublishesNoCommit() { - // Arrange FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); HoldingStore store = new(holdAppendOfTurn: 1, holdAppendNumber: 2); RendezvousChatClient model = new(parties: 2); @@ -62,7 +60,6 @@ public async Task ARefusalThatComesAfterTheBound_FilesNothing_AndPublishesNoComm await using ResponsesHost other = await StartAsync(model, store, time); string conversation = await OpenAsync(host); - // Act Task first = host.PostAsync(Race(conversation, "race A", "user-a")); Task second = other.PostAsync(Race(conversation, "race B", "user-b")); await store.Held.WaitAsync(Ct); @@ -73,7 +70,6 @@ public async Task ARefusalThatComesAfterTheBound_FilesNothing_AndPublishesNoComm List eventsA = Parse(await ResponsesHost.ReadEventsAsync(a)); List eventsB = Parse(await ResponsesHost.ReadEventsAsync(b)); - // Assert List won = Assert.Single([eventsA, eventsB], events => Type(events[^1]) == "response.completed"); List lost = Assert.Single([eventsA, eventsB], events => Type(events[^1]) == "error"); Assert.Equal(ResponsesTurnConflict.Code, lost[^1]["code"]!.GetValue()); @@ -91,14 +87,12 @@ public async Task ARefusalThatComesAfterTheBound_FilesNothing_AndPublishesNoComm [Fact(Timeout = 60_000)] public async Task ASaveThatLandsAfterTheBound_EndsTheTurnAsSaved_AndFilesIt() { - // Arrange FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); HoldingStore store = new(holdAppendOfTurn: 1, holdAppendNumber: 1); RendezvousChatClient model = new(parties: 1); await using ResponsesHost host = await StartAsync(model, store, time); string conversation = await OpenAsync(host); - // Act Task slow = host.PostAsync(Race(conversation, "race A", "user-a")); await store.Held.WaitAsync(Ct); await PassTheBoundAsync(time); @@ -106,7 +100,6 @@ public async Task ASaveThatLandsAfterTheBound_EndsTheTurnAsSaved_AndFilesIt() using HttpResponseMessage response = await slow; List events = Parse(await ResponsesHost.ReadEventsAsync(response)); - // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("response.completed", Type(events[^1])); Assert.Contains(events, frame => frame.ContainsKey(TurnStreamPart.MessageCommitted)); @@ -141,7 +134,7 @@ private static string Race(string conversation, string input, string messageId) /// Moves the clock past the bound, once a bound the turn armed on it, if any, is armed. private static async Task PassTheBoundAsync(FakeTimeProvider time) { - Task armed = time.WaitForTimersAsync(time.GetUtcNow() + ConversationSession.TurnCompletionTimeout, 1); + Task armed = time.WaitForTimersAsync(time.GetUtcNow() + TurnFailureReasons.CompletionTimeout, 1); _ = await Task.WhenAny(armed, Task.Delay(TimeSpan.FromSeconds(1), Ct)); time.Advance(PastTheBound); } diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/SourceWireTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/SourceWireTests.cs index 5297f7a2..ffd6248a 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/SourceWireTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/SourceWireTests.cs @@ -1,21 +1,16 @@ using System.Runtime.CompilerServices; using System.Text.Json; -using AgentCore.Application.Runtime; using AgentCore.Domain.Sources; using AgentCore.AspNetCore.Tests.Fakes; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Turn; namespace AgentCore.AspNetCore.Tests.Endpoints { /// /// The whole wire a source travels: a producer, the conversation's sources, and one extra SSE field. /// - /// - /// Retrieval is the first producer of a source and this test deliberately does not use it. The - /// channel belongs to anything that can name what it read, so what is proved here is that a plain - /// bound tool can cite one and have it reach the browser. - /// public sealed class SourceWireTests { private const string SourceYaml = @@ -63,7 +58,7 @@ public async Task AToolThatCites_ReachesTheBrowserOnItsOwnFieldAndNotInTheReply( Title = "Spirit CT900 owner's manual", Origin = "knowledge", Locator = "p.27", - }); + }, turn.OuterCallId); return ValueTask.FromResult("E03 is an overcurrent trip."); })); @@ -125,7 +120,7 @@ public async Task AToolThatCitesUnderTwoParallelCalls_StampsEachSourceWithItsOwn Kind = SourceKind.Document, Title = what == "left" ? "Left manual" : "Right manual", Origin = "knowledge", - }); + }, turn.OuterCallId); return ValueTask.FromResult("looked up " + what); })); @@ -149,10 +144,6 @@ public async Task AToolThatCitesUnderTwoParallelCalls_StampsEachSourceWithItsOwn } /// Sends one turn of words and reads the answer as it arrives. - /// - /// The dialect member opts the stream into the browser parts: without it the frames carry - /// text alone and a citation would never reach the browser. - /// private static Task PostStreamAsync(ResponsesHost host, string text, string? conversation = null) { return host.PostAsync(conversation is { Length: > 0 } @@ -212,11 +203,6 @@ public void Dispose() } /// Calls the tool it is offered twice in one round — two parallel calls — then answers in words. - /// - /// This is the shape a real model routinely produces and the single-conversation fakes above cannot - /// exercise: FunctionInvokingChatClient batches both tool results of one round onto ONE - /// message before this endpoint ever sees it. - /// private sealed class TwoParallelConversationsChatClient : IChatClient { public async IAsyncEnumerable GetStreamingResponseAsync( diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ActingTimeProvider.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ActingTimeProvider.cs new file mode 100644 index 00000000..ade39023 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ActingTimeProvider.cs @@ -0,0 +1,18 @@ +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// The system clock, except that its first read runs an action first: a way into a window with no await in it. + internal sealed class ActingTimeProvider(Action act) : TimeProvider + { + private int _acted; + + public override DateTimeOffset GetUtcNow() + { + if (Interlocked.Exchange(ref _acted, 1) == 0) + { + act(); + } + + return base.GetUtcNow(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs index d0e099fd..8dfb97c4 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs @@ -1,4 +1,5 @@ -using AgentCore.AspNetCore.Voice; + +using AgentCore.AspNetCore.Voice.Session; namespace AgentCore.AspNetCore.Tests.Fakes { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/CallDecider.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/CallDecider.cs new file mode 100644 index 00000000..f79fd851 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/CallDecider.cs @@ -0,0 +1,15 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// A host hook that decides every offered call with one action. + internal sealed class CallDecider(Action decide) : AgentHook + { + public override ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) + { + decide(gate); + return default; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ClosingAuditSink.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ClosingAuditSink.cs new file mode 100644 index 00000000..95c3bf7a --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ClosingAuditSink.cs @@ -0,0 +1,35 @@ +using AgentCore.Application.Ports; +using AgentCore.Domain.Audit; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// An audit store that is slow to write and records what it held when it was closed. + internal sealed class ClosingAuditSink : IAuditSinkPort, IAsyncDisposable + { + private static readonly TimeSpan WriteDelay = TimeSpan.FromMilliseconds(200); + + private int _written; + + /// Gets the number of events this store has written. + public int Written => Volatile.Read(ref _written); + + /// Gets whether this store was closed. + public bool Closed { get; private set; } + + /// Gets how many events this store had written by the time it was closed. + public int WrittenWhenClosed { get; private set; } + + public async ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) + { + await Task.Delay(WriteDelay, CancellationToken.None); + _ = Interlocked.Increment(ref _written); + } + + public ValueTask DisposeAsync() + { + Closed = true; + WrittenWhenClosed = Written; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FailingCreateStore.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FailingCreateStore.cs new file mode 100644 index 00000000..c0e4b882 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FailingCreateStore.cs @@ -0,0 +1,35 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.TestSupport; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// An in-memory store, and its own adapter of kind test, whose conversation create throws while is set. + internal sealed class FailingCreateStore() : DelegatingConversationStore(new InMemoryConversationStore()), IConversationStoreAdapter + { + private volatile bool _failing = true; + + public bool Failing + { + get => _failing; + set => _failing = value; + } + + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override ValueTask CreateAsync(string conversationId, CancellationToken cancellationToken = default) + { + return _failing + ? throw new InvalidOperationException("the store is down") + : base.CreateAsync(conversationId, cancellationToken); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs index e41bafb0..d5ad4a09 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs @@ -1,5 +1,5 @@ using System.Runtime.CompilerServices; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; namespace AgentCore.AspNetCore.Tests.Fakes { @@ -13,20 +13,6 @@ internal sealed class FakeConversationInput(params ConversationInput[] script) : public int Disposals { get; private set; } /// - /// - /// - /// The refusal is the point of this fake as much as the script is: one socket read twice would - /// give each reader half the conversation, and the port promises a second call throws rather than - /// splitting one conversation in two. - /// - /// - /// The script running out is this fake's end of the conversation, and it completes the stream. A - /// cancelled read throws instead, which is the rule - /// sets and which the real relay connection obeys the same way — a double that ended quietly - /// where the real port throws would teach a consumer a catch it does not need, and hide - /// the one it does. - /// - /// public IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default) { // Thrown from here, not from the iterator below: an iterator's body does not run until diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs index ef99c65f..f941ead6 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs @@ -1,4 +1,5 @@ -using AgentCore.AspNetCore.Voice; + +using AgentCore.AspNetCore.Voice.Ports; namespace AgentCore.AspNetCore.Tests.Fakes { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs new file mode 100644 index 00000000..e6e06ab8 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs @@ -0,0 +1,169 @@ +using System.Text.Json.Nodes; +using System.Threading.Channels; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// A sideband a test feeds by hand; it records what the call sent, in order. + internal sealed class FakeSideband : ILiveSideband + { + private readonly Channel _inbound = Channel.CreateUnbounded(); + private readonly List _sent = []; + private readonly List<(Func Match, TaskCompletionSource Seen)> _waiters = []; + private readonly Lock _reading = new(); + private readonly List _drainWaiters = []; + private int _pushed; + private int _read; + private bool _parked; + + public bool Disposed { get; private set; } + + /// Gets what throws once it closed the socket, or for nothing. + public Exception? DisposeFault { get; init; } + + /// Gets a task every send waits for once it is recorded, or for no wait. + public Task? SendsHeldUntil { get; init; } + + /// Gets how many events the test has pushed so far. + public int Pushed + { + get + { + lock (_reading) + { + return _pushed; + } + } + } + + public IReadOnlyList Sent + { + get + { + lock (_sent) + { + return [.. _sent]; + } + } + } + + public void Push(IEnumerable events) + { + foreach (string liveEvent in events) + { + lock (_reading) + { + _pushed++; + _ = _inbound.Writer.TryWrite(liveEvent); + } + } + } + + /// Pushes GPT-Live's acknowledgement of one append the call sent, in the shape a recorded GPT-Live session showed. + public void Ack(JsonObject sent) + { + JsonObject ack = new() + { + ["type"] = "session.commentary.appended", + ["start_ms"] = 13400, + ["end_ms"] = 13600, + ["event_id"] = "event_test_ack", + ["client_event_id"] = (string?)sent["event_id"], + }; + Push([ack.ToJsonString()]); + } + + /// Closes the socket with no session.closed. + public void Complete() => _inbound.Writer.TryComplete(); + + /// Breaks the socket: the next receive after the pushed events throws . + public void Fail(Exception fault) => _inbound.Writer.TryComplete(fault); + + public Task WaitForSentAsync(Func match) + { + TaskCompletionSource seen = new(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_sent) + { + if (_sent.FirstOrDefault(match) is { } already) + { + return Task.FromResult(already); + } + + _waiters.Add((match, seen)); + } + + return seen.Task; + } + + /// + /// Completes once the call has handled every pushed event and is waiting for the next: the loop asks for a new + /// message only after it handled the last one. + /// + public Task WaitForDrainAsync() + { + lock (_reading) + { + if (_parked && _read == _pushed) + { + return Task.CompletedTask; + } + + TaskCompletionSource drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + _drainWaiters.Add(drained); + return drained.Task; + } + } + + public async ValueTask ReceiveAsync(CancellationToken cancellationToken) + { + while (true) + { + List drained; + lock (_reading) + { + if (_inbound.Reader.TryRead(out string? liveEvent)) + { + _read++; + _parked = false; + return liveEvent; + } + + _parked = true; + drained = _read == _pushed ? [.. _drainWaiters] : []; + _ = _drainWaiters.RemoveAll(drained.Contains); + } + + drained.ForEach(waiter => waiter.TrySetResult()); + if (!await _inbound.Reader.WaitToReadAsync(cancellationToken)) + { + return null; + } + } + } + + public ValueTask SendAsync(string json, CancellationToken cancellationToken) + { + JsonObject sent = JsonNode.Parse(json)!.AsObject(); + List> matched = []; + lock (_sent) + { + _sent.Add(sent); + foreach ((Func match, TaskCompletionSource seen) in _waiters.Where(waiter => waiter.Match(sent)).ToList()) + { + matched.Add(seen); + _ = _waiters.Remove((match, seen)); + } + } + + matched.ForEach(seen => seen.TrySetResult(sent)); + return SendsHeldUntil is { } held ? new ValueTask(held.WaitAsync(cancellationToken)) : default; + } + + public ValueTask DisposeAsync() + { + Disposed = true; + Complete(); + return DisposeFault is { } fault ? ValueTask.FromException(fault) : default; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs index 4f15600c..35b83230 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs @@ -1,11 +1,10 @@ -using AgentCore.TestSupport; using System.Net.WebSockets; using System.Text; using System.Threading.Channels; using AgentCore.Application.Configuration.Parsing; -using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -17,34 +16,6 @@ namespace AgentCore.AspNetCore.Tests.Fakes /// /// A a test drives by hand, with no Kestrel and no client socket. /// - /// - /// - /// is still the right fake for anything the wire owns: framing, a - /// fragmented message, real backpressure, and the close handshake. Three things it cannot reach, - /// and this class exists for exactly those three. - /// - /// - /// - /// The write loop's turn-id gate. The gate sits between the dequeue and the one - /// SendAsync call the connection makes, and no real socket lets a test stop the loop in that - /// gap. does: the write loop reads immediately - /// after it dequeues an item, so parking that one read parks the loop with the item in hand and - /// the gate still ahead of it. - /// - /// - /// The close status. records the arguments rather than the bytes, so - /// a status still reaches a test after a real socket would already have aborted. - /// - /// - /// A faulting send, receive or close. , and - /// make the socket throw on demand, which nothing on a healthy loopback socket does. - /// - /// - /// - /// One queued message is one WebSocket message with one fragment. Fragmentation is a wire behaviour - /// and it already has its own test over a real socket. - /// - /// internal sealed class FakeWebSocket : WebSocket { private static readonly byte[] ReceiveFaultMarker = []; @@ -86,11 +57,6 @@ internal sealed class FakeWebSocket : WebSocket public override string? SubProtocol => null; /// - /// - /// The write loop reads this once for each item it dequeues, before its turn-id gate and before - /// its one send. That makes this property the only observable point between the two, so parking - /// here is what lets a test hold an item in the loop's own hand while a barge-in lands. - /// public override WebSocketState State { get @@ -135,7 +101,6 @@ public void ParkNextStateRead() } /// Lets the parked read of finish, and never parks again. - /// A test calls this from a finally, so a failed assertion never strands the loop. public void ReleaseParkedState() { _parkNextStateRead = false; @@ -226,13 +191,6 @@ public override async ValueTask ReceiveAsync( } /// - /// - /// This is the overload the connection actually reaches. It passes a byte[], and C# - /// resolves that to the parameter rather than to the - /// one. A fake that only overrode the memory form would never - /// see a send at all, and every assertion about what did or did not reach the socket would then - /// pass for the wrong reason. - /// public override Task SendAsync( ArraySegment buffer, WebSocketMessageType messageType, @@ -249,7 +207,6 @@ public override Task SendAsync( } /// - /// Kept beside the form, so either one a caller picks is recorded. public override ValueTask SendAsync( ReadOnlyMemory buffer, WebSocketMessageType messageType, @@ -324,11 +281,6 @@ public override Task CloseOutputAsync( } /// - /// - /// The connection never calls this: CloseAsync waits for the peer's close frame, and a - /// conversation that already dropped never sends one. A conversation that reaches here is a defect worth a red - /// test rather than a silent pass. - /// public override Task CloseAsync( WebSocketCloseStatus closeStatus, string? statusDescription, @@ -338,7 +290,6 @@ public override Task CloseAsync( } /// - /// The connection receives into a , so nothing reaches this form. public override Task ReceiveAsync( ArraySegment buffer, CancellationToken cancellationToken) @@ -353,11 +304,6 @@ public override void Abort() } /// - /// - /// Completing the inbound channel here is what releases a receive the read loop abandoned to - /// its idle race. That receive owns a rented buffer until it finishes, so leaving it pending - /// forever would keep the array out of ArrayPool for the life of the test run. - /// public override void Dispose() { _state = WebSocketState.Closed; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs new file mode 100644 index 00000000..b7a783d5 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs @@ -0,0 +1,83 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// Passes every call to the sessions it wraps. Once ed, the next open parks until + /// or its caller's cancellation; later opens pass straight through. After + /// , the next open throws instead. After , one chosen lookup parks + /// until . After , every lookup throws. + /// + internal sealed class GatedConversationSessions(IConversationSessions inner) : IConversationSessions + { + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _tryGetGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private int _armed; + + private Exception? _fault; + + private int _heldTryGet; + + private Exception? _tryGetFault; + + private int _tryGets; + + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a task that completes once the held lookup reached this store. + public TaskCompletionSource TryGetHeld { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Arm() => Volatile.Write(ref _armed, 1); + + public void Release() => _gate.TrySetResult(); + + public void Fault(Exception fault) => Volatile.Write(ref _fault, fault); + + /// Parks the -th lookup from now on, counting from 1. + public void HoldTryGet(int ordinal) => Volatile.Write(ref _heldTryGet, Volatile.Read(ref _tryGets) + ordinal); + + public void ReleaseTryGet() => _tryGetGate.TrySetResult(); + + public void FaultTryGets(Exception fault) => Volatile.Write(ref _tryGetFault, fault); + + public async ValueTask GetOrOpenAsync( + string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _fault, null) is { } fault) + { + throw fault; + } + + if (Interlocked.Exchange(ref _armed, 0) == 1) + { + _ = Entered.TrySetResult(); + await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return await inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _tryGetFault) is { } fault) + { + throw fault; + } + + if (Interlocked.Increment(ref _tryGets) == Volatile.Read(ref _heldTryGet)) + { + _ = TryGetHeld.TrySetResult(); + await _tryGetGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return await inner.TryGetAsync(entry, conversationId, cancellationToken).ConfigureAwait(false); + } + + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) => + inner.CloseAsync(entry, conversationId, cancellationToken); + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedCreateStore.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedCreateStore.cs new file mode 100644 index 00000000..784a563a --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedCreateStore.cs @@ -0,0 +1,42 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.TestSupport; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// An in-memory store, and its own adapter of kind test, whose first conversation create waits until the test + /// opens the gate. + /// + internal sealed class GatedCreateStore() : DelegatingConversationStore(new InMemoryConversationStore()), IConversationStoreAdapter + { + private int _gated; + + /// Completes when the first create reached the gate. + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Opens the gate. + public TaskCompletionSource Open { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override async ValueTask CreateAsync(string conversationId, CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _gated, 1) == 0) + { + _ = Entered.TrySetResult(); + await Open.Task.WaitAsync(cancellationToken); + } + + return await base.CreateAsync(conversationId, cancellationToken); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedStepChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedStepChatClient.cs new file mode 100644 index 00000000..4ab2ecb1 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedStepChatClient.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// Step one says "Hello there, ", holds until or until its request is cancelled, then + /// says more and calls the tool. A request after a tool result, or for any later words, answers at once. + /// + internal sealed class GatedStepChatClient : IChatClient + { + private readonly TaskCompletionSource _resume = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Held { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ConcurrentQueue> Requests { get; } = new(); + + public void Resume() + { + _ = _resume.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List sent = [.. messages.Where(message => message.Role != ChatRole.System)]; + Requests.Enqueue(sent); + string id = Guid.NewGuid().ToString("N"); + ChatMessage last = sent[^1]; + + if (last.Role == ChatRole.Tool) + { + yield return Text("Done.", id); + yield break; + } + + if (last.Text != "hi") + { + yield return Text("Sure.", id); + yield break; + } + + yield return Text("Hello there, ", id); + _ = Held.TrySetResult(); + try + { + await _resume.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + _ = Stopped.TrySetResult(); + throw; + } + + yield return Text("and more.", id); + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent("call_1", options!.Tools!.OfType().First().Name, new Dictionary())]) + { + ResponseId = id, + MessageId = id, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + + private static ChatResponseUpdate Text(string text, string id) + { + return new ChatResponseUpdate(ChatRole.Assistant, text) { ResponseId = id, MessageId = id }; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs index 7705efb2..394bcba1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs @@ -5,11 +5,21 @@ namespace AgentCore.AspNetCore.Tests.Fakes /// Calls the first tool it is offered, once, then answers in words. internal sealed class GatedToolCallingChatClient : IChatClient { + private int _requests; + + /// Gets a task that completes once a second request reached the model. + public TaskCompletionSource SecondRequest { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (Interlocked.Increment(ref _requests) == 2) + { + _ = SecondRequest.TrySetResult(); + } + await Task.Yield(); bool alreadyCalled = messages.Any(message => message.Contents.OfType().Any()); diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/InstructionsSeenChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/InstructionsSeenChatClient.cs new file mode 100644 index 00000000..e705c3de --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/InstructionsSeenChatClient.cs @@ -0,0 +1,36 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Records the instructions of every streamed request, in call order. + internal sealed class InstructionsSeenChatClient(IChatClient inner) : DelegatingChatClient(inner) + { + private readonly List _instructions = []; + + public IReadOnlyList Instructions + { + get + { + lock (_instructions) + { + return [.. _instructions]; + } + } + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + lock (_instructions) + { + _instructions.Add(options?.Instructions ?? string.Empty); + } + + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs deleted file mode 100644 index 88ac236e..00000000 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Diagnostics.Metrics; -using AgentCore.Application.Diagnostics; - -namespace AgentCore.AspNetCore.Tests.Fakes -{ - /// Collects every measurement this library's histograms take while it is alive. - internal sealed class LatencyReadings : IDisposable - { - private readonly MeterListener _listener = new(); - private readonly List<(string Instrument, double Value)> _readings = []; - private readonly Lock _gate = new(); - - public LatencyReadings() - { - _listener.InstrumentPublished = (instrument, active) => - { - if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) - { - active.EnableMeasurementEvents(instrument); - } - }; - - _listener.SetMeasurementEventCallback((instrument, value, _, _) => - { - lock (_gate) - { - _readings.Add((instrument.Name, value)); - } - }); - - _listener.Start(); - } - - /// Gets what one histogram was told, in order, rounded to the millisecond. - /// The metric name. - /// The measurements, in seconds. - public IReadOnlyList Of(string instrument) - { - lock (_gate) - { - return [.. _readings - .Where(reading => string.Equals(reading.Instrument, instrument, StringComparison.Ordinal)) - .Select(reading => Math.Round(reading.Value, 3))]; - } - } - - public void Dispose() => _listener.Dispose(); - } -} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs new file mode 100644 index 00000000..72593977 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs @@ -0,0 +1,27 @@ +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Keeps every latency reading a voice reply reports, in seconds, by metric. + internal sealed class LatencyRecorder + { + private readonly Lock _gate = new(); + private readonly List<(LatencyMetric Metric, double Seconds)> _readings = []; + + public void Record(LatencyMetric metric, TimeSpan value, int? turnIndex) + { + lock (_gate) + { + _readings.Add((metric, value.TotalSeconds)); + } + } + + public IReadOnlyList Of(LatencyMetric metric) + { + lock (_gate) + { + return [.. _readings.Where(reading => reading.Metric == metric).Select(reading => reading.Seconds)]; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/MissingToolsChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/MissingToolsChatClient.cs new file mode 100644 index 00000000..d3c56e7b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/MissingToolsChatClient.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// Calls, in one round and in the order offered, every tool whose result the request does not hold yet, then answers + /// in words. With , each round calls only the first of them. + /// + internal sealed class MissingToolsChatClient : IChatClient + { + private int _requests; + + private int _callIds; + + public bool OnePerRound { get; init; } + + /// Gets a task that completes once a second request reached the model. + public TaskCompletionSource SecondRequest { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _requests) == 2) + { + _ = SecondRequest.TrySetResult(); + } + + await Task.Yield(); + + List contents = [.. messages.SelectMany(message => message.Contents)]; + HashSet answered = [.. contents.OfType().Select(result => result.CallId)]; + HashSet ran = [.. contents.OfType().Where(call => answered.Contains(call.CallId)).Select(call => call.Name)]; + List calls = + [ + .. (options?.Tools ?? []).OfType() + .Where(tool => !ran.Contains(tool.Name)) + .Take(OnePerRound ? 1 : int.MaxValue) + .Select(tool => new FunctionCallContent("call_" + Interlocked.Increment(ref _callIds), tool.Name, new Dictionary(StringComparer.Ordinal))), + ]; + + yield return calls.Count > 0 + ? new ChatResponseUpdate(ChatRole.Assistant, calls) + : new ChatResponseUpdate(ChatRole.Assistant, "done."); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs new file mode 100644 index 00000000..dddbd677 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs @@ -0,0 +1,175 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Hooks; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Vendors.OpenAiLive; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.AspNetCore.Voice.Routing; +using AgentCore.TestSupport; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// One real host on Kestrel with the openai-live vendor behind app.MapCall(), a recording + /// call-control API, and fake sidebands. No network, no OpenAI account. + /// + internal sealed class OpenAiLiveHost : IAsyncDisposable + { + /// The Standard Webhooks spec's example secret. + public const string WebhookSecret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + + public const string ApiKey = "sk-test"; + + public const string MainCall = "/v1/main/call"; + + public const string LiveYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: + clock: false + model: { ref: reply } + items: + - { id: only, instructions: "answer the caller" } + providers: + conversation: + kind: openai-live + live: + instructions: "Be brief." + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: only + """; + + private readonly WebApplication _app; + private readonly HttpClient _client; + private readonly AgentCoreHttpClients _pipeline; + + private OpenAiLiveHost(WebApplication app, HttpClient client, AgentCoreHttpClients pipeline, RecordingLiveControl control, List attached) + { + _app = app; + _client = client; + _pipeline = pipeline; + Control = control; + Attached = attached; + } + + public RecordingLiveControl Control { get; } + + /// Gets every sideband the adapter attached, in order. + public List Attached { get; } + + public TaskCompletionSource FirstAttach { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public IServiceProvider Services => _app.Services; + + /// Starts the host. + /// Names the fault the sideband attach of a call throws, or for none. + /// Anything else the test binds on the options, such as a conversation store. + public static async Task StartAsync( + IChatClient model, + IReadOnlyList hooks, + MapSecretResolver? secrets = null, + string yaml = LiveYaml, + Func? attachFault = null, + Action? configure = null) + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); + _ = builder.Logging.ClearProviders(); + + RecordingLiveControl control = new(); + AgentCoreHttpClients pipeline = new(control); + List attached = []; + OpenAiLiveHost? host = null; + OpenAiLiveConversationAdapter adapter = new( + pipeline, + secrets ?? new MapSecretResolver().With("openai-api-key", ApiKey).With("openai-webhook-secret", WebhookSecret), + (attach, token) => + { + if (attachFault?.Invoke(attach) is { } fault) + { + throw fault; + } + + FakeSideband sideband = new(); + lock (attached) + { + attached.Add(sideband); + } + + _ = host?.FirstAttach.TrySetResult(sideband); + return ValueTask.FromResult(sideband); + }, + new Uri("https://api.openai.test/")); + + _ = builder.Services.AddAgentCore(options => + { + options.Configuration = ConfigurationLoader.LoadYaml(yaml); + _ = options.UseChatClients(_ => new RoutingChatClientFactory(model)); + _ = options.UseConversation(adapter); + _ = options.UseHooks([.. hooks]); + configure?.Invoke(options); + }); + + WebApplication app = builder.Build(); + _ = app.MapCall(); + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + pipeline.Dispose(); + throw; + } + + string address = app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + host = new OpenAiLiveHost(app, new HttpClient { BaseAddress = new Uri(address) }, pipeline, control, attached); + return host; + } + + /// Posts one webhook, signed the Standard Webhooks way with an implementation of the test's own. + public Task PostWebhookAsync(string body, string webhookId = "wh_1", DateTimeOffset? signedAt = null, string? secret = null) + { + string timestamp = (signedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture); + byte[] key = Convert.FromBase64String((secret ?? WebhookSecret)["whsec_".Length..]); + string signature = Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{webhookId}.{timestamp}.{body}"))); + + HttpRequestMessage request = new(HttpMethod.Post, MainCall) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + request.Headers.Add("webhook-id", webhookId); + request.Headers.Add("webhook-timestamp", timestamp); + request.Headers.Add("webhook-signature", "v1," + signature); + return _client.SendAsync(request, TestContext.Current.CancellationToken); + } + + public static string IncomingCall(string callId) => + $$$"""{"object":"event","id":"evt_{{{callId}}}","type":"realtime.call.incoming","created_at":1790500000,"data":{"call_id":"{{{callId}}}","sip_headers":[{"name":"From","value":""},{"name":"To","value":""}]}}"""; + + public Task StopAsync() => _app.StopAsync(TestContext.Current.CancellationToken); + + public async ValueTask DisposeAsync() + { + lock (Attached) + { + Attached.ForEach(sideband => sideband.Complete()); + } + + _client.Dispose(); + await _app.DisposeAsync(); + _pipeline.Dispose(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs new file mode 100644 index 00000000..ea16d254 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs @@ -0,0 +1,100 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Layers; +using AgentCore.Application.Ports; +using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.Calls; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Real sessions and compiled hooks for , over a fake model. + internal sealed class PhoneCallHarness : IDisposable + { + internal const string OneEntryYaml = """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "answer the caller", tools: [ price_lookup ] } + entries: + main: + agent: only + other: + agent: only + """; + + private PhoneCallHarness(InMemoryConversationSessions sessions, PhoneCallHost host, CallBriefHook briefs) + { + Sessions = sessions; + Host = host; + Briefs = briefs; + } + + public InMemoryConversationSessions Sessions { get; } + + public PhoneCallHost Host { get; } + + public CallBriefHook Briefs { get; } + + public static PhoneCallHarness Create( + IChatClient model, + IReadOnlyList hooks, + string yaml = OneEntryYaml, + TimeProvider? time = null, + IConversationStore? store = null, + Func? tools = null) + { + TimeProvider clock = time ?? TimeProvider.System; + CallBriefHook briefs = new(); + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + IReadOnlyDictionary compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(new RoutingChatClientFactory(model)) + { + ConversationStore = store ?? new InMemoryConversationStore(clock), + Tools = TestToolRegistry.From( + document, + tools ?? (tool => AIFunctionFactory.Create(() => "{\"price\":42}", tool.Id, tool.Description ?? tool.Id)), + TestContext.Current.CancellationToken), + Hooks = [briefs, .. hooks], + Clock = clock, + }); + + Dictionary factories = new(StringComparer.Ordinal); + foreach ((string entry, CompiledAgent agent) in compiled) + { + factories[entry] = new ConversationSessionFactory(agent, new GuardEvaluator(agent.Configuration.Guards), timeProvider: clock); + } + + InMemoryConversationSessions sessions = new(factories, TimeSpan.FromMinutes(5), clock); + PhoneCallHost host = new(sessions, compiled[SingleEntrySessionFactories.MainEntry].Hooks, clock, NullLogger.Instance, GatePoint.BeforeCall.Deadline); + return new PhoneCallHarness(sessions, host, briefs); + } + + public static CallOffer Offer(string callId = "call-7", string entry = SingleEntrySessionFactories.MainEntry) + { + return new CallOffer( + entry, callId, "+15550100", "+15550199", + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Diversion"] = "" }, + "test-transport"); + } + + public void Dispose() + { + Sessions.Dispose(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs new file mode 100644 index 00000000..447774fe --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs @@ -0,0 +1,35 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Ports; +using AgentCore.Domain.Audit; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// An audit store that keeps what it accepted, so what the document opened is observable. + internal sealed class RecordingAuditSink : IAuditSinkPort + { + private readonly Lock _gate = new(); + private readonly List _events = []; + + /// Gets the events this store accepted, in the order they arrived. + public IReadOnlyList Events + { + get + { + lock (_gate) + { + return [.. _events]; + } + } + } + + public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) + { + lock (_gate) + { + _events.Add(auditEvent); + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingLiveControl.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingLiveControl.cs new file mode 100644 index 00000000..30a79799 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingLiveControl.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Stands where OpenAI's call-control API would be: records each request and answers 200 unless told otherwise. + internal sealed class RecordingLiveControl : HttpMessageHandler + { + private readonly List _requests = []; + + public Func? Answer { get; set; } + + public IReadOnlyList Requests + { + get + { + lock (_requests) + { + return [.. _requests]; + } + } + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string? body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + string path = request.RequestUri!.AbsolutePath; + lock (_requests) + { + _requests.Add(new Seen(request.Method.Method, path, request.Headers.Authorization?.ToString(), body is null ? null : JsonNode.Parse(body))); + } + + return Answer?.Invoke(path) ?? new HttpResponseMessage(HttpStatusCode.OK); + } + + internal sealed record Seen(string Method, string Path, string? Authorization, JsonNode? Body); + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs index 023e3e63..09db2d50 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs @@ -1,9 +1,10 @@ -using AgentCore.TestSupport; using System.Net.WebSockets; using AgentCore.Application.Configuration.Parsing; -using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection; +using AgentCore.AspNetCore.Voice.Routing; +using AgentCore.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs new file mode 100644 index 00000000..abf17a50 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs @@ -0,0 +1,69 @@ +using AgentCore.Application.Hooks; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// One started call whose loop reads a , on a clock that + /// stands still at in UTC. + /// + internal sealed class RunningLiveCall : IAsyncDisposable + { + public static readonly DateTimeOffset Noon = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + + private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCall call, FakeSideband sideband) + { + Harness = harness; + Time = time; + Call = call; + Sideband = sideband; + Loop = Task.CompletedTask; + } + + public PhoneCallHarness Harness { get; } + + public FakeTimeProvider Time { get; } + + public PhoneCall Call { get; } + + public FakeSideband Sideband { get; } + + public Task Loop { get; private set; } + + /// Gets the hang-up, completed with how many events the test had pushed when the call hung up. + public TaskCompletionSource HungUp { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public static async Task StartAsync( + IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null) + { + CancellationToken ct = TestContext.Current.CancellationToken; + FakeTimeProvider time = new(Noon) { Zone = TimeZoneInfo.Utc }; + PhoneCallHarness harness = PhoneCallHarness.Create(model, hooks, yaml, time: time); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), ct)).Call!; + await call.StartAsync(ct); + RunningLiveCall running = new(harness, time, call, sideband ?? new FakeSideband()); + OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger ?? NullLogger.Instance); + running.Loop = live.RunAsync(ct); + return running; + } + + public async ValueTask DisposeAsync() + { + Sideband.Complete(); + await Loop.WaitAsync(TimeSpan.FromSeconds(10)); + Harness.Dispose(); + } + + private Task HangUpAsync(CancellationToken cancellationToken) + { + _ = HungUp.TrySetResult(Sideband.Pushed); + return Task.FromResult(true); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs index 0a07c21e..daa0de26 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs @@ -1,8 +1,10 @@ -using AgentCore.Application.Runtime; using AgentCore.Application.Ports; using AgentCore.Domain; using AgentCore.Domain.Knowledge; using Microsoft.Extensions.AI; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.AspNetCore.Tests.Fakes { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs index 00e925d2..7a481070 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs @@ -5,7 +5,9 @@ namespace AgentCore.AspNetCore.Tests.Fakes { /// /// A model that answers "reply to" the caller's last words, except words that start with : those - /// get one piece, then a stall that only the run's cancellation (a client abort) ends. + /// get one piece, then a stall that only the run's cancellation (a client abort) ends. With , + /// its first request sends nothing and waits until the run is cancelled, so a test can correct a question while it + /// is still being answered. /// internal sealed class StallOnCueChatClient : IChatClient { @@ -15,12 +17,43 @@ internal sealed class StallOnCueChatClient : IChatClient /// The piece a stalled reply sends before it stalls. public const string Piece = "partial"; + private readonly List> _requests = []; + + private int _calls; + + public bool HoldFirst { get; init; } + + public TaskCompletionSource FirstEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public IReadOnlyList> Requests + { + get + { + lock (_requests) + { + return [.. _requests]; + } + } + } + public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - string last = messages.LastOrDefault(message => message.Role == ChatRole.User)?.Text ?? string.Empty; + List sent = [.. messages]; + lock (_requests) + { + _requests.Add(sent); + } + + if (HoldFirst && Interlocked.Increment(ref _calls) == 1) + { + _ = FirstEntered.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + + string last = sent.LastOrDefault(message => message.Role == ChatRole.User)?.Text ?? string.Empty; if (!last.StartsWith(Cue, StringComparison.Ordinal)) { yield return new ChatResponseUpdate(ChatRole.Assistant, "reply to " + last); diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs index 27814a05..b2511be3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs @@ -1,11 +1,11 @@ -using AgentCore.TestSupport; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.AspNetCore.Voice; +using AgentCore.Application.Runtime.Session; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice.Routing; +using AgentCore.TestSupport; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -20,12 +20,6 @@ namespace AgentCore.AspNetCore.Tests.Fakes /// /// One real host with the relay route mapped, over a fake model. /// - /// - /// Kestrel takes port zero and reports the port it got, so many tests run at once. The socket is - /// real on purpose: framing, fragmentation, and the close handshake are wire behaviours, and the - /// in-memory socket of TestServer never frames anything. No test here reaches a network, and no - /// test needs a Telnyx account. That is T59. - /// internal sealed class TelnyxRelayHost : IAsyncDisposable { /// The vendor-neutral conversation route, with the document's one entry filled in. @@ -53,23 +47,9 @@ private TelnyxRelayHost(WebApplication app, Uri socketAddress, HttpClient client public IServiceProvider Services => _app.Services; /// Gets the ws:// address of the relay route. - /// - /// covers every test that wants the vendor side driven for it. A - /// test that needs to control exactly when bytes are read off the wire — proving something - /// about backpressure, for one, since 's own pump always drains - /// the socket as fast as it can — opens its own - /// against this address instead. - /// public Uri Address { get; } /// Gets the last exception any part of the host logged at error level, or null. - /// - /// A host that forgot app.UseWebSockets() answers every relay request with an unhandled - /// exception, and there is no field on the endpoint itself that carries it — only the log line - /// ASP.NET Core's own hosting layer writes when a request delegate throws. This reads that line, - /// captured by , which this host always installs - /// regardless of what a test's own logging callback adds. - /// public string? LastError => _errors.LastMessage; /// Starts one host over one document, with the WebSocket middleware in place. @@ -103,12 +83,6 @@ public static Task StartAsync( /// /// Anything a test adds to the logging pipeline. /// The started host, answering on . - /// - /// maps the relay through the internal MapTelnyxRelay and so - /// bypasses the seam entirely, which is right for the several dozen frame-level tests that use - /// it and wrong for proving that the shipped vendor is reachable from the vendor-neutral route. - /// This overload is the one that joins the two. - /// public static Task StartThroughConversationSeamAsync( string yaml, IChatClient reply, @@ -124,10 +98,6 @@ public static Task StartThroughConversationSeamAsync( /// Anything else the test binds on the options. /// Anything a test adds to the logging pipeline. /// The started host. - /// - /// The middleware is the host's own job, not the library's — MapTelnyxRelay only maps the - /// route. This is how a test proves what happens when a host forgets it. - /// public static Task StartWithoutWebSocketsAsync( string yaml, IChatClient reply, @@ -255,12 +225,6 @@ public async Task WaitForConversationEndAsync(string conversationId) /// Waits until the store holds one conversation. /// The conversation id. /// A task that completes once the session appears. - /// - /// A setup frame's SendAsync completing on the client only means the bytes left the - /// client; it proves nothing about whether the server has read and dispatched them yet. A test - /// that then tears the socket down immediately would otherwise race the server's own session - /// creation, and a pass would not prove the removal it meant to prove. - /// /// The session never appeared within two seconds. public async Task WaitForSessionAsync(string conversationId) { @@ -287,13 +251,6 @@ public async ValueTask DisposeAsync() } /// Captures the message of the last exception any category logged at error level or above. - /// - /// ASP.NET Core's own hosting layer logs an unhandled exception from a request delegate rather than - /// letting a test observe it any other way, so this is the only handle - /// has on it. Every category is captured, not just the relay connection's own logger, because the - /// missing-middleware fault this exists for is thrown and logged by the framework, not by - /// AgentCore.TelnyxRelay. - /// internal sealed class ErrorCapturingLoggerProvider : ILoggerProvider { private volatile string? _lastMessage; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TestAuditSinkAdapter.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TestAuditSinkAdapter.cs new file mode 100644 index 00000000..2f7f790f --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TestAuditSinkAdapter.cs @@ -0,0 +1,19 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Ports; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// An audit vendor a test registers, which opens one store the test already holds. + internal sealed class TestAuditSinkAdapter(IAuditSinkPort store) : IAuditSinkAdapter + { + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, + ISecretResolverPort? secrets, + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(store); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs index 6986fdb2..27e1e24e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs @@ -1,4 +1,5 @@ -using AgentCore.AspNetCore.Voice; + +using AgentCore.AspNetCore.Voice.Session; namespace AgentCore.AspNetCore.Tests.Fakes { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs index 6253c527..93f23819 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs @@ -3,10 +3,16 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks; +using AgentCore.Application.Runtime.Session; using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Transport; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; @@ -30,8 +36,9 @@ private VoiceLoopHarness(IChatClient reply, IConversationOutputPort output) TimeSpan.FromMinutes(5), TimeProvider.System); Loop = new VoiceConversationLoop( - Sessions, + new PhoneCallHost(Sessions, compiled.Hooks, TimeProvider.System, NullLogger.Instance, GatePoint.BeforeCall.Deadline), SingleEntrySessionFactories.MainEntry, + "test-transport", output, new ConnectionTaskObserver(() => Loop?.ConversationId ?? "(none)", (_, _) => { }, (_, _, _) => { }, (_, _, _) => false), TimeProvider.System, diff --git a/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs b/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs new file mode 100644 index 00000000..0b92ef2b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs @@ -0,0 +1,265 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Secrets; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Tests.DependencyInjection; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; +using AgentCore.Application.Runtime.Session; +using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; + +namespace AgentCore.AspNetCore.Tests.Hooks +{ + public sealed class HookRegistrationTests + { + private static readonly DateTimeOffset FixedStart = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // UseHooks resolves the hook once from DI; one instance serves every conversation. + [Fact(Timeout = 60_000)] + public async Task AHookTypeIsTheInstanceTheContainerHolds() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new()); + _ = builder.Services.AddSingleton(); + ConfigureServices(builder.Services, OneAgentYaml, options => options.UseHooks()); + using StartedHost provider = await StartAsync(builder.Build()); + + ConversationSession first = provider.GetRequiredService().ForFactory("main").Create("c1"); + ConversationSession second = provider.GetRequiredService().ForFactory("main").Create("c2"); + _ = await first.RunTurnAsync("hi", Ct); + _ = await second.RunTurnAsync("hi", Ct); + await first.FlushNoticesAsync(); + await second.FlushNoticesAsync(); + + RecordingHook hook = provider.GetRequiredService(); + Assert.Equal(["c1", "c2"], hook.Of().Select(n => n.Scope.ConversationId).Order(StringComparer.Ordinal)); + } + + [Fact(Timeout = 60_000)] + public async Task UseHooksTwiceKeepsBothInOrder() + { + RecordingHook a = new(); + RecordingHook b = new(); + using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.UseHooks(a).UseHooks(b)); + + ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("c1"); + _ = await session.RunTurnAsync("hi", Ct); + await session.FlushNoticesAsync(); + + _ = Assert.Single(a.Of()); + _ = Assert.Single(b.Of()); + } + + // The host says it started and is stopping, stamped by the clock the options name. + [Fact(Timeout = 60_000)] + public async Task TheHostAnnouncesItsStartAndStop() + { + FakeTimeProvider clock = new(FixedStart); + RecordingHook hook = new(); + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new()); + ConfigureServices(builder.Services, OneAgentYaml, options => + { + options.TimeProvider = clock; + _ = options.UseHooks(hook); + }); + IHost host = builder.Build(); + + await host.StartAsync(Ct); + HostStarted started = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + clock.Advance(TimeSpan.FromMinutes(3)); + await host.StopAsync(Ct); + host.Dispose(); + + Assert.Equal(["main"], started.Entries); + Assert.Equal(0, started.ToolCount); + Assert.Null(started.Scope.ConversationId); + Assert.Equal(FixedStart, started.Scope.OccurredAt); + HostStopping stopping = Assert.Single(hook.Of()); + Assert.Equal(FixedStart + TimeSpan.FromMinutes(3), stopping.Scope.OccurredAt); + } + + // A host whose boot failed still stops: the boot's own error is the one the host sees. + [Fact(Timeout = 60_000)] + public async Task AHostWhoseBootFailedStillStops() + { + const string StoredYaml = $$""" + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + {{MinimalProviders}} + conversations: { kind: test } + entries: + main: + agent: only + """; + RecordingHook hook = new(); + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new()); + ConfigureServices(builder.Services, StoredYaml, options => + { + _ = options.UseConversationStores(new DownStore()); + _ = options.UseHooks(hook); + }); + using IHost host = builder.Build(); + + InvalidOperationException failed = await Assert.ThrowsAsync(() => host.StartAsync(Ct)); + await host.StopAsync(Ct); + + Assert.Equal(DownStore.Message, failed.Message); + Assert.Empty(hook.Notices); + } + + // The hourly sweep reports how many rows it deleted. + [Fact(Timeout = 60_000)] + public async Task TheRetentionSweepReportsWhatItDeleted() + { + const string SweptYaml = $$""" + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + {{MinimalProviders}} + conversations: { kind: test } + entries: + main: + agent: only + """; + FakeTimeProvider clock = new(FixedStart); + RecordingHook hook = new(); + using StartedHost provider = await BuildAsync(SweptYaml, options => + { + _ = options.UseConversationStores(new SevenRowSweep(clock)); + options.TimeProvider = clock; + options.ResponseRetention = TimeSpan.FromDays(30); + _ = options.UseHooks(hook); + }); + + await clock.WaitForTimersAsync(clock.GetUtcNow() + TimeSpan.FromHours(1), 1); + clock.Advance(TimeSpan.FromHours(1)); + RetentionSwept swept = await hook.WaitForAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Assert.Equal(7, swept.Deleted); + Assert.Equal(FixedStart + TimeSpan.FromHours(1), swept.Scope.OccurredAt); + } + + // The container does not close until the queued host notices are delivered or the host's + // shutdown timeout runs out. + [Fact(Timeout = 30_000)] + public async Task TheDrainWaitsForAHookUntilTheHostShutdownTimeout() + { + FakeTimeProvider clock = new(FixedStart); + HoldingHook hook = new(); + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new()); + _ = builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromSeconds(5)); + ConfigureServices(builder.Services, OneAgentYaml, options => + { + options.TimeProvider = clock; + _ = options.UseHooks(hook); + }); + IHost host = builder.Build(); + await host.StartAsync(Ct); + DateTimeOffset stopped = clock.GetUtcNow(); + + await host.StopAsync(Ct); + await hook.Entered.WaitAsync(TimeSpan.FromSeconds(10), Ct); + Task disposing = ((IAsyncDisposable)host).DisposeAsync().AsTask(); + await clock.WaitForTimersAsync(stopped + TimeSpan.FromSeconds(5), 1).WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Assert.False(disposing.IsCompleted); + clock.Advance(TimeSpan.FromSeconds(5)); + await disposing.WaitAsync(TimeSpan.FromSeconds(10), Ct); + } + + // Flushing the audit queue first waits for every notice already raised to the hooks. + [Fact(Timeout = 30_000)] + public async Task FlushingTheAuditQueueWaitsForTheHooksFirst() + { + TurnHoldingHook hook = new(); + using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.UseHooks(hook)); + ConversationSession session = provider.GetRequiredService().ForFactory("main").Create("c1"); + _ = await session.RunTurnAsync("hi", Ct); + await hook.Entered.WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Task flushing = AddAgentCoreAuditTests.Queue(provider).FlushAsync(Ct).AsTask(); + bool waitedForTheHook = !flushing.IsCompleted; + hook.Release(); + await flushing.WaitAsync(TimeSpan.FromSeconds(10), Ct); + + Assert.True(waitedForTheHook); + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], + AddAgentCoreAuditTests.Sink(provider).EventsOf("c1").Select(row => row.Kind)); + } + + private sealed class TurnHoldingHook : AgentHook + { + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Entered => _entered.Task; + + public override TimeSpan? NoticeTimeout => null; + + public void Release() => _release.TrySetResult(); + + public override async ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + _ = _entered.TrySetResult(); + await _release.Task.ConfigureAwait(false); + } + } + + private sealed class HoldingHook : AgentHook + { + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Entered => _entered.Task; + + public override TimeSpan? NoticeTimeout => null; + + public override async ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) + { + _ = _entered.TrySetResult(); + await new TaskCompletionSource().Task.ConfigureAwait(false); + } + } + + private sealed class DownStore : IConversationStoreAdapter + { + internal const string Message = "The conversation store is down."; + + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException(Message); + } + } + + private sealed class SevenRowSweep(TimeProvider clock) + : DelegatingConversationStore(new InMemoryConversationStore(clock)), IConversationStoreAdapter + { + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override ValueTask SweepAsync(TimeSpan retention, int batchSize = 500, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(7); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs b/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs index 818bdcb5..91a4026b 100644 --- a/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs @@ -12,6 +12,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Sessions { @@ -57,7 +58,7 @@ public async Task AnUnknownKey_KeepsResolvingToTheSameConversation_PastTheRetent _ = await agent.RunAsync("hi", first, cancellationToken: Ct); await sessions.SaveSessionAsync(agent, "external-thread-1", first, Ct); - // D3: the key already names its own conversation row, so SaveSessionAsync wrote no response + // The key already names its own conversation row, so SaveSessionAsync wrote no response // row for it — nothing here for a sweep to find. clock.Advance(TimeSpan.FromDays(31)); int swept = await store.SweepAsync(TimeSpan.FromDays(30), cancellationToken: Ct); diff --git a/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs b/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs index 3f774b4f..05e29fbe 100644 --- a/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs @@ -7,8 +7,8 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; @@ -18,16 +18,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Sessions { /// /// What the relay socket asks of over the life of one conversation. /// - /// - /// The unit tests of the store itself live in AgentCore.Application.Tests beside the store. This - /// file holds only what needs a real socket to prove. - /// public sealed class ConversationSessionsRelayTests { private const string TwoEntryPolicyYaml = @@ -107,8 +104,8 @@ public async Task ASetupNamingAnIdAnotherEntryHoldsClosesAsAPolicyViolationAndLe QueuedAuditSink queue = Assert.IsType(harness.Services.GetRequiredService()); await queue.FlushAsync(Token); IReadOnlyList chain = Assert.IsType(queue.Store).EventsOf("conversation-held"); - _ = Assert.Single(chain, item => item.Kind == AuditEventKind.ConversationStarted); - Assert.DoesNotContain(chain, item => item.Kind == AuditEventKind.ConversationEnded); + Assert.Equal([AuditEventKind.ConversationStarted, AuditEventKind.TurnRefused], chain.Select(item => item.Kind)); + Assert.Equal("in_use", chain[1].Payload[AuditPayloadKeys.RefusedReason]); Assert.Same(held, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-held", Token)); Assert.False(held.IsComplete); @@ -128,6 +125,7 @@ public async Task ARefusedReopenAfterClosingTheReplacedSessionLeavesTheOtherEntr configure: options => { _ = options.UseWorkspace(root); + _ = options.UseHooks(new CallDecider(gate => gate.Accept("conversation-race"))); _ = options.UseConversationSessions(factories => new ForcedReopenConversationSessions( new InMemoryConversationSessions(factories, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System), closingEntry: "main", @@ -135,10 +133,11 @@ public async Task ARefusedReopenAfterClosingTheReplacedSessionLeavesTheOtherEntr }, entry: "main"); - // Two setup frames for the same id: the loop closes its own hold, and in that window this - // forces "other" to open the id before the loop's reopen is attempted. - harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-race")); - harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-race")); + // Two setup frames for two calls the hook puts on one conversation: the loop closes the first + // call's hold, and in that window this forces "other" to open the id before the second + // call's open is attempted. + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "call-first")); + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "call-second")); await harness.Connection.WaitAsync(Token); Assert.Equal(WebSocketCloseStatus.PolicyViolation, harness.Socket.CloseSent?.Status); diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/CommentaryPiecesTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/CommentaryPiecesTests.cs new file mode 100644 index 00000000..a98f58ce --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/CommentaryPiecesTests.cs @@ -0,0 +1,33 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// An append carries at most 500 tokens: pieces are at most 1,500 characters, cut at a sentence end. + public sealed class CommentaryPiecesTests + { + [Fact] + public void AShortReplyIsOnePiece() + { + Assert.Equal(["The F80 tops out at 10.5 mph."], CommentaryPieces.Split("The F80 tops out at 10.5 mph.")); + } + + [Fact] + public void ALongReplyIsCutAtSentenceEndsUnderTheLimit() + { + string text = string.Join(' ', Enumerable.Range(1, 200).Select(n => $"Sentence number {n:D3} is here.")); + + IReadOnlyList pieces = CommentaryPieces.Split(text); + + Assert.True(pieces.Count > 1); + Assert.All(pieces, piece => Assert.True(piece.Length <= 1500 && piece.EndsWith('.'))); + Assert.Equal(text, string.Join(' ', pieces)); + } + + [Fact] + public void TextWithNoSentenceEndOrSpaceIsCutAtTheLimit() + { + Assert.Equal([1500, 500], CommentaryPieces.Split(new string('x', 2000)).Select(piece => piece.Length)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p1-a.jsonl b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p1-a.jsonl new file mode 100644 index 00000000..2e4b6e9f --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p1-a.jsonl @@ -0,0 +1,71 @@ +{"t": 0.0, "dir": "out", "event": {"type": "session.start", "session": {"model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness. Be brief. You cannot see orders yourself. For any order question, delegate to the backend and wait for its answer.", "audio": {"format": {"type": "audio/pcm", "rate": 24000}, "output": {"voice": "marin"}}, "delegation": {"type": "client"}}, "event_id": "c1"}} +{"t": 0.282, "dir": "in", "event": {"event_id": "event_ESev6L9Z4vXu2jY4tWfcy", "type": "session.started", "client_event_id": "c1", "session": {"id": "live_u7_ESev6p3oMVi8ok4kVufJAq08AR0pnQqS", "expires_at": 1790506388, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness. Be brief. You cannot see orders yourself. For any order question, delegate to the backend and wait for its answer.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}}} +{"t": 1.003, "dir": "caller", "line": "hello", "seconds": 1.1} +{"t": 2.054, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1200, "end_ms": 1400, "delta": " Hi", "event_id": "event_ESev8BrvR0yiMYPWq4Bee"}} +{"t": 2.447, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1600, "end_ms": 1800, "delta": " there", "event_id": "event_ESev8QHi82AFUGrsdmCbt"}} +{"t": 2.938, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 2000, "end_ms": 2200, "delta": " Hey!", "event_id": "event_ESev90H5CWyMa8dCqR4ts"}} +{"t": 3.563, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 2600, "end_ms": 2800, "delta": " Thanks", "event_id": "event_ESev9llm39DAc1zGLHnpD"}} +{"t": 3.764, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 2800, "end_ms": 3000, "delta": " for", "event_id": "event_ESev9gs6NXWyWttNswvkq"}} +{"t": 4.018, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 3000, "end_ms": 3200, "delta": " calling", "event_id": "event_ESevAGrVN4w3CzWqwRXsv"}} +{"t": 4.165, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 3200, "end_ms": 3400, "delta": " Sole", "event_id": "event_ESevAj9tMY0NdvTvcoj2X"}} +{"t": 4.35, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 3400, "end_ms": 3600, "delta": " Fitness.", "event_id": "event_ESevAS6EVH86zHTznl1kp"}} +{"t": 4.958, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 4000, "end_ms": 4200, "delta": " How", "event_id": "event_ESevBQsK649iWKDQP6Kj7"}} +{"t": 5.004, "dir": "caller", "line": "order", "seconds": 5.6} +{"t": 5.188, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 4200, "end_ms": 4400, "delta": " can", "event_id": "event_ESevBgMuInTOMNHzxxQ7U"}} +{"t": 5.354, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 4400, "end_ms": 4600, "delta": " I help", "event_id": "event_ESevBKSOoqRuKUfp0xpGZ"}} +{"t": 5.547, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 4600, "end_ms": 4800, "delta": "?", "event_id": "event_ESevBS5FeyJKIdznXQGI4"}} +{"t": 6.475, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 5600, "end_ms": 5800, "delta": " Can", "event_id": "event_ESevCPDFMwHTbtlYOfR6z"}} +{"t": 6.685, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 5800, "end_ms": 6000, "delta": " you", "event_id": "event_ESevCB0arGux4GYKaUKyP"}} +{"t": 6.883, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 6000, "end_ms": 6200, "delta": " check", "event_id": "event_ESevDHqKzwuthO1woPi2a"}} +{"t": 7.045, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 6200, "end_ms": 6400, "delta": " the", "event_id": "event_ESevDlYixVXWDIN9s3KPJ"}} +{"t": 7.515, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 6600, "end_ms": 6800, "delta": " status", "event_id": "event_ESevDgM7O2Xs2GV31Youk"}} +{"t": 7.646, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 6800, "end_ms": 7000, "delta": " of my", "event_id": "event_ESevDsAhzrVXpYEDsnhdA"}} +{"t": 8.046, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 7200, "end_ms": 7400, "delta": " order", "event_id": "event_ESevEyzhn65or6BFfens2"}} +{"t": 8.647, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 7800, "end_ms": 8000, "delta": "? The", "event_id": "event_ESevEbX9KZbjwGdgbbeUb"}} +{"t": 9.059, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 8200, "end_ms": 8400, "delta": " order", "event_id": "event_ESevFtA8FfnPR7ktFxBEi"}} +{"t": 9.49, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 8600, "end_ms": 8800, "delta": " number", "event_id": "event_ESevFtVNfIvwCpkXbToMs"}} +{"t": 9.689, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 8800, "end_ms": 9000, "delta": " is", "event_id": "event_ESevFdUpMPOESdxUK1TuF"}} +{"t": 9.856, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 9000, "end_ms": 9200, "delta": " A", "event_id": "event_ESevGMh9l8FxSpb8HlE93"}} +{"t": 10.451, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 9600, "end_ms": 9800, "delta": " four four", "event_id": "event_ESevGd8TIZ3MXtWZ9YKqC"}} +{"t": 10.842, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10000, "end_ms": 10200, "delta": " seven", "event_id": "event_ESevHhMGCo4Qxg6F8GrVm"}} +{"t": 11.258, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10400, "end_ms": 10600, "delta": " one", "event_id": "event_ESevHbhtLZyRwhKP5Pxx4"}} +{"t": 11.26, "dir": "in", "event": {"type": "session.delegation.created", "offset_ms": 10400, "delegation": {"id": "item_ESevHlqkAJkwt1wThn5gW", "type": "delegation", "target": "client"}, "event_id": "event_ESevHpUZf9dUmLlFdDFcF"}} +{"t": 11.26, "dir": "note", "input_at_delegation": " Hi there Can you check the status of my order? The order number is A four four seven one", "output_at_delegation": " Hey! Thanks for calling Sole Fitness. How can I help?"} +{"t": 11.764, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 10800, "end_ms": 11000, "delta": " Sure.", "event_id": "event_ESevHaZL6I1QvMIoxoKao"}} +{"t": 12.261, "dir": "out", "event": {"type": "session.thinking.append", "delegation_id": "item_ESevHlqkAJkwt1wThn5gW", "content": "Looking up order A4471. Nothing is known yet.", "event_id": "c2"}} +{"t": 12.395, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 11400, "end_ms": 11600, "delta": " Checking", "event_id": "event_ESevI3KQF5Ckmp2L4DpJ1"}} +{"t": 12.744, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 11800, "end_ms": 12000, "delta": " that", "event_id": "event_ESevIhapHIN5XPqTMxFqz"}} +{"t": 12.744, "dir": "in", "event": {"type": "session.thinking.appended", "start_ms": 11800, "end_ms": 12000, "event_id": "event_ESevIEFGDA92eiHO7ktg2", "client_event_id": "c2"}} +{"t": 13.24, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 12200, "end_ms": 12400, "delta": " now.", "event_id": "event_ESevJ4306UmnoMoQwVF52"}} +{"t": 13.762, "dir": "out", "event": {"type": "session.commentary.append", "delegation_id": "item_ESevHlqkAJkwt1wThn5gW", "content": "Order A4471 shipped on September 24 with UPS. It should arrive on Tuesday, September 29.", "event_id": "c3"}} +{"t": 14.351, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13400, "end_ms": 13600, "delta": " Okay.", "event_id": "event_ESevKyWgS1agTMv7ino2d"}} +{"t": 14.354, "dir": "in", "event": {"type": "session.commentary.appended", "start_ms": 13400, "end_ms": 13600, "event_id": "event_ESevK47WC7xnvbBG2eUPH", "client_event_id": "c3"}} +{"t": 14.769, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13800, "end_ms": 14000, "delta": " That", "event_id": "event_ESevKIyZI4p6rSE4dOUr4"}} +{"t": 14.965, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14000, "end_ms": 14200, "delta": " order", "event_id": "event_ESevLAHuOtedCDWnuII7y"}} +{"t": 15.124, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 14.0}, "context_window": {"usage_ratio": 0.0106640625}, "event_id": "event_ESevLUGxS2CQPyTuxU4tS"}} +{"t": 15.18, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14200, "end_ms": 14400, "delta": " shipped", "event_id": "event_ESevL9VCeGe4P12pQ2qfx"}} +{"t": 15.37, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14400, "end_ms": 14600, "delta": " on", "event_id": "event_ESevLGVRgqRpJ1XF6Np0X"}} +{"t": 15.556, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14600, "end_ms": 14800, "delta": " September", "event_id": "event_ESevLmCjq9whrYmDt6JXp"}} +{"t": 15.979, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15000, "end_ms": 15200, "delta": " 24", "event_id": "event_ESevM0ZaJdF1VQAc53si0"}} +{"t": 16.771, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15800, "end_ms": 16000, "delta": " with", "event_id": "event_ESevM6EbkQsTyoYQ4OvKY"}} +{"t": 16.969, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16000, "end_ms": 16200, "delta": " UPS.", "event_id": "event_ESevN8ZX4Sbqg5Y5Qxu8G"}} +{"t": 17.981, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17000, "end_ms": 17200, "delta": " It should", "event_id": "event_ESevOEJVlaeVxAGTeegjf"}} +{"t": 18.165, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17200, "end_ms": 17400, "delta": " arrive", "event_id": "event_ESevOC4Gl2sxBfY702oOw"}} +{"t": 18.589, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17600, "end_ms": 17800, "delta": " on Tuesday", "event_id": "event_ESevOw7NUjtmGD4oHY5sp"}} +{"t": 18.743, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17800, "end_ms": 18000, "delta": ",", "event_id": "event_ESevOaKqHsCYOhNhK50m7"}} +{"t": 19.135, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 18200, "end_ms": 18400, "delta": " September", "event_id": "event_ESevPjg0q9Ci7OA0XciPI"}} +{"t": 19.574, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 18600, "end_ms": 18800, "delta": " 29", "event_id": "event_ESevPCKXQqz491qezNBqH"}} +{"t": 19.755, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 18800, "end_ms": 19000, "delta": ".", "event_id": "event_ESevPhZZ8ujYq8U4BquxJ"}} +{"t": 28.004, "dir": "caller", "line": "thanks", "seconds": 3.2} +{"t": 29.445, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 28600, "end_ms": 28800, "delta": " Okay", "event_id": "event_ESevZpchSmL1QmgUAs1YK"}} +{"t": 30.125, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 29.0}, "context_window": {"usage_ratio": 0.0142265625}, "event_id": "event_ESevadScsCmFRhHF7UBCu"}} +{"t": 30.655, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 29800, "end_ms": 30000, "delta": ". Thanks", "event_id": "event_ESevaLkzckYINePgWtyLc"}} +{"t": 31.782, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 30800, "end_ms": 31000, "delta": ". That's", "event_id": "event_ESevcDKndXMmupjReNs40"}} +{"t": 31.981, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 31000, "end_ms": 31200, "delta": " all", "event_id": "event_ESevcnkl38fvvTECXpND5"}} +{"t": 31.982, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 31000, "end_ms": 31200, "delta": " You're welcome", "event_id": "event_ESevcgG9mdOOOPFtlQnDm"}} +{"t": 32.152, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 31200, "end_ms": 31400, "delta": ".", "event_id": "event_ESevcPLmqoDe0zBH29vEW"}} +{"t": 32.581, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 31600, "end_ms": 31800, "delta": " Take", "event_id": "event_ESevcySz2W4E2gwhg35VM"}} +{"t": 32.749, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 31800, "end_ms": 32000, "delta": " care!", "event_id": "event_ESevcxIszegkvzmglkrZv"}} +{"t": 38.284, "dir": "out", "event": {"type": "session.close", "event_id": "c4"}} +{"t": 38.938, "dir": "in", "event": {"event_id": "event_ESevjmY2RfRGAR3M9rkOB", "type": "session.closed", "reason": "close_requested", "session": {"id": "live_u7_ESev6p3oMVi8ok4kVufJAq08AR0pnQqS", "expires_at": 1790506388, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness. Be brief. You cannot see orders yourself. For any order question, delegate to the backend and wait for its answer.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}, "usage": {"seconds": 37.0}, "client_event_id": "c4"}} +{"t": 38.938, "dir": "note", "output_audio_bytes": 1769280} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p2-strict-0.jsonl b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p2-strict-0.jsonl new file mode 100644 index 00000000..3dd0ade8 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p2-strict-0.jsonl @@ -0,0 +1,39 @@ +{"t": 0.0, "dir": "out", "event": {"type": "session.start", "session": {"model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"format": {"type": "audio/pcm", "rate": 24000}, "output": {"voice": "marin"}}, "delegation": {"type": "client"}}, "event_id": "c1"}} +{"t": 0.408, "dir": "in", "event": {"event_id": "event_ESeww70vMQeGeP4vGIsA3", "type": "session.started", "client_event_id": "c1", "session": {"id": "live_u7_ESewwp3oN3zUoOnhMIWowdQOTjS9rztF", "expires_at": 1790506502, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}}} +{"t": 1.009, "dir": "caller", "line": "speed_f63", "seconds": 4.2} +{"t": 2.23, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1200, "end_ms": 1400, "delta": " What", "event_id": "event_ESewyceqHtKs9jnxGdlB5"}} +{"t": 2.408, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1400, "end_ms": 1600, "delta": " is", "event_id": "event_ESewyrGfHRRge7vNCtYBM"}} +{"t": 2.585, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1600, "end_ms": 1800, "delta": " the", "event_id": "event_ESewzQxf6q8L1v02V9h33"}} +{"t": 3.191, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2200, "end_ms": 2400, "delta": " maximum", "event_id": "event_ESewzfNwqX5t12g4CDdy7"}} +{"t": 3.627, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2600, "end_ms": 2800, "delta": " speed", "event_id": "event_ESex0u4S8YFiLJDIly8H8"}} +{"t": 3.807, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2800, "end_ms": 3000, "delta": " of", "event_id": "event_ESex0ZhVFczd9tyzJFqK3"}} +{"t": 4.013, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3000, "end_ms": 3200, "delta": " the", "event_id": "event_ESex0xiqkFTe83eZDdHVU"}} +{"t": 4.43, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3400, "end_ms": 3600, "delta": " Sole", "event_id": "event_ESex0aCS9IeOjYpfWMyBp"}} +{"t": 4.582, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3600, "end_ms": 3800, "delta": " F", "event_id": "event_ESex1ATXDSIxfsmG6V7bc"}} +{"t": 5.252, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 4200, "end_ms": 4400, "delta": "63", "event_id": "event_ESex1JmYjmsRcub4pQ5Zf"}} +{"t": 5.822, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 4800, "end_ms": 5000, "delta": " treadmill", "event_id": "event_ESex28h51AuWpvWoOtYm3"}} +{"t": 5.824, "dir": "in", "event": {"type": "session.delegation.created", "offset_ms": 4800, "delegation": {"id": "item_ESex215P1yTmbiQWp35JV", "type": "delegation", "target": "client"}, "event_id": "event_ESex2tbQGHONXppyRmTf6"}} +{"t": 6.305, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5200, "end_ms": 5400, "delta": " ", "event_id": "event_ESex2AmayQMWyNyczGlHr"}} +{"t": 6.514, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5400, "end_ms": 5600, "delta": "Let", "event_id": "event_ESex3i0BFaZKJK9v0HHKY"}} +{"t": 6.705, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5600, "end_ms": 5800, "delta": " me", "event_id": "event_ESex3I5n46oYtIQqj9uTE"}} +{"t": 6.896, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5800, "end_ms": 6000, "delta": " check", "event_id": "event_ESex3kl3JmZhlWhhM6syD"}} +{"t": 7.104, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 6000, "end_ms": 6200, "delta": " that", "event_id": "event_ESex3cGEu9BzFq6ARKpXq"}} +{"t": 7.325, "dir": "out", "event": {"type": "session.commentary.append", "delegation_id": "item_ESex215P1yTmbiQWp35JV", "content": "The Sole F63 has a top speed of 11.5 miles per hour.", "event_id": "c2"}} +{"t": 7.504, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 6400, "end_ms": 6600, "delta": " real", "event_id": "event_ESex4nCLLBGBBde7Rco7B"}} +{"t": 7.692, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 6600, "end_ms": 6800, "delta": " quick", "event_id": "event_ESex47kqM0sc5gC3wWdtT"}} +{"t": 7.903, "dir": "in", "event": {"type": "session.commentary.appended", "start_ms": 6800, "end_ms": 7000, "event_id": "event_ESex4nKFhUIa8zzwwcQA2", "client_event_id": "c2"}} +{"t": 8.084, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 7000, "end_ms": 7200, "delta": "...", "event_id": "event_ESex4poSDoRN68pAil0Sj"}} +{"t": 9.11, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 8000, "end_ms": 8200, "delta": " The Sole", "event_id": "event_ESex5ulUCoSCXgQfmGZMG"}} +{"t": 9.295, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 8200, "end_ms": 8400, "delta": " F63", "event_id": "event_ESex5RXbVmB5HQZNs6T2d"}} +{"t": 10.109, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 9000, "end_ms": 9200, "delta": " goes", "event_id": "event_ESex6vuz9aV3UdnB8AwH9"}} +{"t": 10.303, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 9200, "end_ms": 9400, "delta": " up", "event_id": "event_ESex6xcxSrpUNhAwSndsX"}} +{"t": 10.519, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 9400, "end_ms": 9600, "delta": " to", "event_id": "event_ESex7SKib5c6BzLR9bsYW"}} +{"t": 10.712, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 9600, "end_ms": 9800, "delta": " 11", "event_id": "event_ESex7DBXS1cLuwrYZT6tr"}} +{"t": 10.874, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 9800, "end_ms": 10000, "delta": ".5", "event_id": "event_ESex7Yl0KJYKaY4zj0Vkb"}} +{"t": 11.496, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 10400, "end_ms": 10600, "delta": " miles", "event_id": "event_ESex8ukh5BHfiqbmoFeP4"}} +{"t": 11.728, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 10600, "end_ms": 10800, "delta": " per", "event_id": "event_ESex8VrhJ1BDfMseebvjq"}} +{"t": 11.901, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 10800, "end_ms": 11000, "delta": " hour.", "event_id": "event_ESex8TYd8zqxsBi5isubS"}} +{"t": 15.231, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 14.0}, "context_window": {"usage_ratio": 0.010390625}, "event_id": "event_ESexBNyqRfrB0kEUzwo3o"}} +{"t": 22.409, "dir": "out", "event": {"type": "session.close", "event_id": "c3"}} +{"t": 23.029, "dir": "in", "event": {"event_id": "event_ESexJLRBD0AESfwgV9qtH", "type": "session.closed", "reason": "close_requested", "session": {"id": "live_u7_ESewwp3oN3zUoOnhMIWowdQOTjS9rztF", "expires_at": 1790506502, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}, "usage": {"seconds": 21.0}, "client_event_id": "c3"}} +{"t": 23.029, "dir": "note", "output_audio_bytes": 1021440} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-drop-0.jsonl b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-drop-0.jsonl new file mode 100644 index 00000000..e56b6f49 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-drop-0.jsonl @@ -0,0 +1,58 @@ +{"t": 0.0, "dir": "out", "event": {"type": "session.start", "session": {"model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"format": {"type": "audio/pcm", "rate": 24000}, "output": {"voice": "marin"}}, "delegation": {"type": "client"}}, "event_id": "c1"}} +{"t": 0.342, "dir": "in", "event": {"event_id": "event_ESexMCyv68RXHtRikiKGe", "type": "session.started", "client_event_id": "c1", "session": {"id": "live_u7_ESexLp3oN3zUWXuDzMxsCKauhkORebVO", "expires_at": 1790506528, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}}} +{"t": 1.004, "dir": "caller", "line": "speed_f63", "seconds": 4.2} +{"t": 2.242, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1200, "end_ms": 1400, "delta": " What", "event_id": "event_ESexONh5d1oR5YsjsXOKK"}} +{"t": 2.386, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1400, "end_ms": 1600, "delta": " is", "event_id": "event_ESexOucJQ3ELhMQXgEhkE"}} +{"t": 2.531, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1600, "end_ms": 1800, "delta": " the", "event_id": "event_ESexOYCMrpQavfEBftAkz"}} +{"t": 3.165, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2200, "end_ms": 2400, "delta": " maximum", "event_id": "event_ESexPjtka9WxNYPWmZkrn"}} +{"t": 3.789, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2800, "end_ms": 3000, "delta": " speed of", "event_id": "event_ESexPiis0e3MErp23cDxn"}} +{"t": 3.946, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3000, "end_ms": 3200, "delta": " the", "event_id": "event_ESexPPqWlgt1bAGnGTKns"}} +{"t": 4.387, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3400, "end_ms": 3600, "delta": " Sole", "event_id": "event_ESexQGn31ycNC9UyfNKYY"}} +{"t": 4.552, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3600, "end_ms": 3800, "delta": " F", "event_id": "event_ESexQBWgLR2N4WTNqlRVa"}} +{"t": 5.173, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 4200, "end_ms": 4400, "delta": "63", "event_id": "event_ESexRsDwgp4tn4S4sH9jQ"}} +{"t": 5.951, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 5000, "end_ms": 5200, "delta": " treadmill?", "event_id": "event_ESexR73Jgo8pAYGLtW4pJ"}} +{"t": 5.951, "dir": "in", "event": {"type": "session.delegation.created", "offset_ms": 5000, "delegation": {"id": "item_ESexRCCGscju2z3LSZNCl", "type": "delegation", "target": "client"}, "event_id": "event_ESexREWv8sbL1sYOHR6DN"}} +{"t": 6.455, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5400, "end_ms": 5600, "delta": " Hmm,", "event_id": "event_ESexSze4SjKKaLOsMj9jQ"}} +{"t": 7.062, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 6000, "end_ms": 6200, "delta": " alright,", "event_id": "event_ESexSAVDQqYzfttlCMUXu"}} +{"t": 7.929, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 6800, "end_ms": 7000, "delta": " I\u2019ll", "event_id": "event_ESexTeEvWmr3pxXl5GIEa"}} +{"t": 7.965, "dir": "caller", "line": "correct_f80", "seconds": 4.9} +{"t": 8.102, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 7000, "end_ms": 7200, "delta": " go", "event_id": "event_ESexUuZsNmHVxBbEY8nmy"}} +{"t": 8.305, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 7200, "end_ms": 7400, "delta": " check", "event_id": "event_ESexUA2DHO70nyYEk5XOJ"}} +{"t": 8.457, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 7400, "end_ms": 7600, "delta": " that.", "event_id": "event_ESexUeCWdAs7is5Ozae6Y"}} +{"t": 9.6, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 8600, "end_ms": 8800, "delta": " Oh", "event_id": "event_ESexVJpWbb5yZm0P42CL8"}} +{"t": 9.752, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 8800, "end_ms": 9000, "delta": ", wait", "event_id": "event_ESexVJiscrkXzRBcKTeHQ"}} +{"t": 10.56, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 9600, "end_ms": 9800, "delta": ", sorry", "event_id": "event_ESexWkfOYwEdk0L5eVYWj"}} +{"t": 10.998, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10000, "end_ms": 10200, "delta": ". I", "event_id": "event_ESexWM4KZBBsg8PF1j79y"}} +{"t": 11.188, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10200, "end_ms": 10400, "delta": " meant", "event_id": "event_ESexXudSt7D7iDkWaPzuB"}} +{"t": 11.408, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10400, "end_ms": 10600, "delta": " the", "event_id": "event_ESexXyCzUu4ajyqkJts8m"}} +{"t": 11.544, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10600, "end_ms": 10800, "delta": " F", "event_id": "event_ESexXnz9GcxnRAFckyh2U"}} +{"t": 11.943, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 11000, "end_ms": 11200, "delta": "80", "event_id": "event_ESexX8LhicCARX14xfQXR"}} +{"t": 12.63, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 11600, "end_ms": 11800, "delta": ", not", "event_id": "event_ESexYxy730ndfnRDXLsu6"}} +{"t": 12.793, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 11800, "end_ms": 12000, "delta": " the", "event_id": "event_ESexYdKWYeHyN6nRRLu9b"}} +{"t": 12.959, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 12000, "end_ms": 12200, "delta": " F", "event_id": "event_ESexY0gwUxhbO3AbtqgaF"}} +{"t": 13.563, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 12600, "end_ms": 12800, "delta": "63", "event_id": "event_ESexZhUqFxTvvrPzTLF45"}} +{"t": 13.902, "dir": "in", "event": {"type": "session.delegation.created", "offset_ms": 12800, "delegation": {"id": "item_ESexZMJMkWUoowj5D7061", "type": "delegation", "target": "client"}, "event_id": "event_ESexZyfnnSp8iCfUkQLb6"}} +{"t": 14.107, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13000, "end_ms": 13200, "delta": " Sure,", "event_id": "event_ESexaDKNSjVSV84QI2LPg"}} +{"t": 14.273, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13200, "end_ms": 13400, "delta": " I\u2019ll", "event_id": "event_ESexaBiWQoW3W5GAZKiCs"}} +{"t": 14.646, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13400, "end_ms": 13600, "delta": " check", "event_id": "event_ESexavdlhhdhsDGnz9ggm"}} +{"t": 14.738, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13600, "end_ms": 13800, "delta": " the", "event_id": "event_ESexaVA6UUdsk1YJkt3UN"}} +{"t": 14.86, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13800, "end_ms": 14000, "delta": " F80", "event_id": "event_ESexaBymmbcsXWgh44IN1"}} +{"t": 15.113, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 14.0}, "context_window": {"usage_ratio": 0.01003125}, "event_id": "event_ESexbUdzdtsE0AtYnMzum"}} +{"t": 15.324, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14200, "end_ms": 14400, "delta": ".", "event_id": "event_ESexbmfCZLR3mZqSd7Pbx"}} +{"t": 15.903, "dir": "out", "event": {"type": "session.commentary.append", "delegation_id": "item_ESexZMJMkWUoowj5D7061", "content": "The Sole F80 has a top speed of 10.5 miles per hour.", "event_id": "c2"}} +{"t": 16.507, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15400, "end_ms": 15600, "delta": " The", "event_id": "event_ESexc7XOxRh7yU6ERoVQV"}} +{"t": 16.693, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15600, "end_ms": 15800, "delta": " Sole", "event_id": "event_ESexcePHAACvmBZpWsxix"}} +{"t": 16.694, "dir": "in", "event": {"type": "session.commentary.appended", "start_ms": 15600, "end_ms": 15800, "event_id": "event_ESexc7PERQC877ylcgVKu", "client_event_id": "c2"}} +{"t": 16.867, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15800, "end_ms": 16000, "delta": " F80", "event_id": "event_ESexcWd2RCoKpYPgP7yXz"}} +{"t": 17.494, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16400, "end_ms": 16600, "delta": " goes", "event_id": "event_ESexdlSPiLbAftlF4GT0z"}} +{"t": 17.667, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16600, "end_ms": 16800, "delta": " up", "event_id": "event_ESexdX3b77zvLEoPPdKGT"}} +{"t": 17.878, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16800, "end_ms": 17000, "delta": " to ", "event_id": "event_ESexdgchV2wDhsv9UgCVl"}} +{"t": 18.136, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17000, "end_ms": 17200, "delta": "10.", "event_id": "event_ESexemZ80zdQDwVExtt5O"}} +{"t": 18.238, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17200, "end_ms": 17400, "delta": "5", "event_id": "event_ESexeykfHouIHNgy4w0kP"}} +{"t": 18.846, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17800, "end_ms": 18000, "delta": " miles", "event_id": "event_ESexe2V97csuoZix4DivI"}} +{"t": 19.302, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 18200, "end_ms": 18400, "delta": " per hour", "event_id": "event_ESexfiSYwmAPlyJu3lxkF"}} +{"t": 19.455, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 18400, "end_ms": 18600, "delta": ".", "event_id": "event_ESexfC5pxvMlDijb5GKbN"}} +{"t": 30.116, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 29.0}, "context_window": {"usage_ratio": 0.0134609375}, "event_id": "event_ESexqDITPcnI0SJ3iGh7D"}} +{"t": 32.343, "dir": "out", "event": {"type": "session.close", "event_id": "c3"}} +{"t": 33.001, "dir": "in", "event": {"event_id": "event_ESexsTgVr0RYw1PUQ8JjQ", "type": "session.closed", "reason": "close_requested", "session": {"id": "live_u7_ESexLp3oN3zUWXuDzMxsCKauhkORebVO", "expires_at": 1790506528, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}, "usage": {"seconds": 31.0}, "client_event_id": "c3"}} +{"t": 33.001, "dir": "note", "output_audio_bytes": 1473600} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-stale-0.jsonl b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-stale-0.jsonl new file mode 100644 index 00000000..be16f1eb --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/Fixtures/p3-stale-0.jsonl @@ -0,0 +1,58 @@ +{"t": 0.0, "dir": "out", "event": {"type": "session.start", "session": {"model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"format": {"type": "audio/pcm", "rate": 24000}, "output": {"voice": "marin"}}, "delegation": {"type": "client"}}, "event_id": "c1"}} +{"t": 0.362, "dir": "in", "event": {"event_id": "event_ESexM0q8zS7i2yBTOoGXD", "type": "session.started", "client_event_id": "c1", "session": {"id": "live_u7_ESexLp3oN7VZaPcgrrC5rWkYOqCSrhDM", "expires_at": 1790506528, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}}} +{"t": 1.003, "dir": "caller", "line": "speed_f63", "seconds": 4.2} +{"t": 2.164, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1200, "end_ms": 1400, "delta": "What", "event_id": "event_ESexOH2XAWuWov4wVkSAD"}} +{"t": 2.366, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1400, "end_ms": 1600, "delta": " is", "event_id": "event_ESexOpJ8IdSWQHIcddPBB"}} +{"t": 2.534, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 1600, "end_ms": 1800, "delta": " the", "event_id": "event_ESexOrMFqMGr9USVQ1DNu"}} +{"t": 3.129, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2200, "end_ms": 2400, "delta": " maximum", "event_id": "event_ESexPUS0aP5cpY4amzFId"}} +{"t": 3.569, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2600, "end_ms": 2800, "delta": " speed", "event_id": "event_ESexPg8PV1qtC16SDGrIH"}} +{"t": 3.776, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 2800, "end_ms": 3000, "delta": " of", "event_id": "event_ESexPUJX6l9cesd8UWLYD"}} +{"t": 3.921, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3000, "end_ms": 3200, "delta": " the", "event_id": "event_ESexPkcwoh7k9huqX5r3g"}} +{"t": 4.361, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3400, "end_ms": 3600, "delta": " Sole", "event_id": "event_ESexQVvPfRKMWTT6WE0mu"}} +{"t": 4.518, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 3600, "end_ms": 3800, "delta": " F", "event_id": "event_ESexQK9vgcGRt2kcyp91Y"}} +{"t": 5.129, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 4200, "end_ms": 4400, "delta": "63", "event_id": "event_ESexR65wPxpzSe5NUH5CR"}} +{"t": 6.078, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 5000, "end_ms": 5200, "delta": " treadmill", "event_id": "event_ESexSoMNEfuRbiJKOBZHS"}} +{"t": 6.078, "dir": "in", "event": {"type": "session.delegation.created", "offset_ms": 5000, "delegation": {"id": "item_ESexSsIl2XxGxcM1lsQFb", "type": "delegation", "target": "client"}, "event_id": "event_ESexSgJl3fOo7fb2PLOsp"}} +{"t": 6.239, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5200, "end_ms": 5400, "delta": " I'll check", "event_id": "event_ESexSMioBepP9fn6f2LFj"}} +{"t": 6.666, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5600, "end_ms": 5800, "delta": " that in", "event_id": "event_ESexSAI5B9qvl8NjnCN50"}} +{"t": 6.861, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 5800, "end_ms": 6000, "delta": " our", "event_id": "event_ESexS7oBQgDmWXN9UHMg4"}} +{"t": 7.041, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 6000, "end_ms": 6200, "delta": " specs.", "event_id": "event_ESexTDDw34sSOyWJrm6dS"}} +{"t": 8.083, "dir": "caller", "line": "correct_f80", "seconds": 4.9} +{"t": 9.535, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 8600, "end_ms": 8800, "delta": " Oh", "event_id": "event_ESexVJ4JffPSmkfQ3AjpH"}} +{"t": 9.928, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 9000, "end_ms": 9200, "delta": " wait", "event_id": "event_ESexV4Qx3K6HT4mhGYenm"}} +{"t": 10.563, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 9600, "end_ms": 9800, "delta": ", sorry", "event_id": "event_ESexWFqbqh00iTmossJbR"}} +{"t": 11.024, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10000, "end_ms": 10200, "delta": ". I", "event_id": "event_ESexWwnXGCVDJ5Sst8fEE"}} +{"t": 11.156, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10200, "end_ms": 10400, "delta": " meant", "event_id": "event_ESexXUh4peBVkyt9973G3"}} +{"t": 11.384, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10400, "end_ms": 10600, "delta": " the", "event_id": "event_ESexXtVWbSFAHbseDiC7o"}} +{"t": 11.794, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 10800, "end_ms": 11000, "delta": " F", "event_id": "event_ESexXQbzgNAJc4jTFTPHa"}} +{"t": 11.931, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 11000, "end_ms": 11200, "delta": "80", "event_id": "event_ESexXBxAByHBGfO86b3rF"}} +{"t": 12.786, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 11800, "end_ms": 12000, "delta": " not the", "event_id": "event_ESexYkAkIHA7MKcLmEOYs"}} +{"t": 12.928, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 12000, "end_ms": 12200, "delta": " F", "event_id": "event_ESexYu6QjiSohcGLPWnPX"}} +{"t": 13.719, "dir": "in", "event": {"type": "session.input_transcript.delta", "start_ms": 12800, "end_ms": 13000, "delta": "63", "event_id": "event_ESexZI6A4uhHQ9wGZ9Hmd"}} +{"t": 13.719, "dir": "in", "event": {"type": "session.delegation.created", "offset_ms": 12800, "delegation": {"id": "item_ESexZiu0OmTOVioUO7wxZ", "type": "delegation", "target": "client"}, "event_id": "event_ESexZLpfM7qPlzPaZPzSh"}} +{"t": 14.078, "dir": "out", "event": {"type": "session.commentary.append", "delegation_id": "item_ESexSsIl2XxGxcM1lsQFb", "content": "The Sole F63 has a top speed of 11.5 miles per hour.", "event_id": "c2"}} +{"t": 14.258, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13200, "end_ms": 13400, "delta": "No problem", "event_id": "event_ESexaajXf3c4DXF3Ka7sR"}} +{"t": 14.43, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13400, "end_ms": 13600, "delta": ",", "event_id": "event_ESexaWw5fJ2mNX3iS37ZH"}} +{"t": 14.653, "dir": "in", "event": {"type": "session.commentary.appended", "start_ms": 13600, "end_ms": 13800, "event_id": "event_ESexacEXrYcVw1Hz5PERz", "client_event_id": "c2"}} +{"t": 14.852, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 13800, "end_ms": 14000, "delta": " I'll", "event_id": "event_ESexafg0Eu7MJmm1u3XAj"}} +{"t": 15.046, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14000, "end_ms": 14200, "delta": " switch", "event_id": "event_ESexbzhchauJedbTyB7HC"}} +{"t": 15.122, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 14.0}, "context_window": {"usage_ratio": 0.0099453125}, "event_id": "event_ESexbs1JckrPfR7RTI4Zz"}} +{"t": 15.255, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14200, "end_ms": 14400, "delta": " to", "event_id": "event_ESexb4xf4htH54mbueWcu"}} +{"t": 15.427, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 14400, "end_ms": 14600, "delta": " that.", "event_id": "event_ESexblwOpB7rjNmcf4uXV"}} +{"t": 15.72, "dir": "out", "event": {"type": "session.commentary.append", "delegation_id": "item_ESexZiu0OmTOVioUO7wxZ", "content": "The Sole F80 has a top speed of 10.5 miles per hour.", "event_id": "c3"}} +{"t": 16.258, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15200, "end_ms": 15400, "delta": " The Sole", "event_id": "event_ESexcWuAYhSkEt3UsD5mh"}} +{"t": 16.448, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15400, "end_ms": 15600, "delta": " F80", "event_id": "event_ESexcTlPS1DBQfzBj2xsE"}} +{"t": 16.448, "dir": "in", "event": {"type": "session.commentary.appended", "start_ms": 15400, "end_ms": 15600, "event_id": "event_ESexc1HaL6KmOcjrNSt8h", "client_event_id": "c3"}} +{"t": 16.852, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 15800, "end_ms": 16000, "delta": " has", "event_id": "event_ESexc8nBbJFLrxJvtX2WP"}} +{"t": 17.036, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16000, "end_ms": 16200, "delta": " a top", "event_id": "event_ESexdsfyLQm2dIe1SewgS"}} +{"t": 17.463, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16400, "end_ms": 16600, "delta": " speed", "event_id": "event_ESexdJYS7S0HyV8HZ4yaI"}} +{"t": 17.657, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16600, "end_ms": 16800, "delta": " of", "event_id": "event_ESexdSNshvSe4Nu4IdQiZ"}} +{"t": 17.874, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 16800, "end_ms": 17000, "delta": " 10", "event_id": "event_ESexdDxEKYF6xqms020r0"}} +{"t": 18.124, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17000, "end_ms": 17200, "delta": ".5", "event_id": "event_ESexe0FY5iwAAgJndlOzv"}} +{"t": 18.951, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17600, "end_ms": 17800, "delta": " miles", "event_id": "event_ESexeUCuetQp7CCxzFG5y"}} +{"t": 19.099, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 17800, "end_ms": 18000, "delta": " per", "event_id": "event_ESexftFr9pao8TMUy3PvC"}} +{"t": 19.203, "dir": "in", "event": {"type": "session.output_transcript.delta", "start_ms": 18000, "end_ms": 18200, "delta": " hour.", "event_id": "event_ESexfoTmQxDjzsiUS1LQI"}} +{"t": 30.123, "dir": "in", "event": {"type": "session.usage.updated", "usage": {"seconds": 29.0}, "context_window": {"usage_ratio": 0.0134140625}, "event_id": "event_ESexqeptmE2RAEMpbFuxP"}} +{"t": 32.363, "dir": "out", "event": {"type": "session.close", "event_id": "c4"}} +{"t": 33.043, "dir": "in", "event": {"event_id": "event_ESextSx375pHrd6cilXd5", "type": "session.closed", "reason": "close_requested", "session": {"id": "live_u7_ESexLp3oN7VZaPcgrrC5rWkYOqCSrhDM", "expires_at": 1790506528, "model": "gpt-live-1", "instructions": "You are a phone support agent for Sole Fitness treadmills. Be brief. You know no product facts yourself. For any question about products, specifications, orders, parts, or policies, delegate to the backend and wait for its answer. Never guess a fact.", "audio": {"output": {"voice": "marin"}, "format": {"type": "audio/pcm", "rate": 24000}}, "delegation": {"type": "client"}, "status": "active", "input": []}, "usage": {"seconds": 31.0}, "client_event_id": "c4"}} +{"t": 33.043, "dir": "note", "output_audio_bytes": 1460160} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs new file mode 100644 index 00000000..ce4b93c5 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs @@ -0,0 +1,47 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The sideband reader names what it understands and turns every other frame into an event; it never throws. + public sealed class LiveEventReaderTests + { + private static readonly Dictionary Expected = new() + { + ["not json at all"] = new LiveEvent.Other("unparseable"), + ["""{"delta":"hi"}"""] = new LiveEvent.Other(string.Empty), + ["""{"type":"session.input_transcript.delta","delta":"hi","start_ms":0}"""] = new LiveEvent.Other("session.input_transcript.delta"), + ["""{"type":"session.delegation.created","delegation":{},"offset_ms":10}"""] = new LiveEvent.Other("session.delegation.created"), + ["""{"type":"error"}"""] = new LiveEvent.Failed(null, null), + ["[1,2]"] = new LiveEvent.Other(string.Empty), + ["""{"type":"session.input_transcript.delta","delta":"hi","start_ms":1200.5,"end_ms":1800.0}"""] = new LiveEvent.Transcript(Speaker.Caller, "hi", 1200, 1800), + }; + + public static TheoryData UnusableFrames => new(Expected.Keys); + + [Fact] + public void TheReaderNamesAnUnmodelledEventAndAnError() + { + Assert.Equal(new LiveEvent.Other("session.usage.updated"), LiveEventReader.Read("""{"type":"session.usage.updated","usage":{"seconds":14.0}}""")); + Assert.Equal(new LiveEvent.Failed("bad_delegation", "no such delegation"), LiveEventReader.Read("""{"type":"error","error":{"code":"bad_delegation","message":"no such delegation"}}""")); + } + + // The ack names the append it acknowledges by our event_id, echoed as client_event_id. + [Fact] + public void TheReaderNamesTheAppendACommentaryAckAcknowledges() + { + IReadOnlyList log = LiveLog.Inbound("p1-a"); + + Assert.Equal(new LiveEvent.Appended("c3"), LiveEventReader.Read(log[LiveLog.IndexOf(log, "session.commentary.appended")])); + } + + // The read loop hangs up on a throw, so a frame it cannot use must come back as an event, never an exception. + [Theory] + [MemberData(nameof(UnusableFrames))] + public void TheReaderTurnsAFrameItCannotUseIntoAnEvent(string json) + { + Assert.Equal(Expected[json], LiveEventReader.Read(json)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs new file mode 100644 index 00000000..9e8642cd --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs @@ -0,0 +1,30 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The strict paragraph is always added; the brief is appended verbatim. + public sealed class LiveInstructionsTests + { + // The paragraph that made GPT-Live delegate in 6 of 6 probe runs, word for word. + private const string Strict = + "You know no product facts yourself. For any question about products, specifications, orders," + + " parts, or policies, delegate to the backend and wait for its answer. Never guess a fact." + + " You cannot end the call yourself: when the caller wants to end or hang up the call, delegate that" + + " to the backend, and say goodbye with its answer."; + + [Fact] + public void TheConfiguredPromptAlwaysGetsTheStrictParagraph() + { + Assert.Equal("You are a phone support agent. Be brief.\n\n" + Strict, LiveInstructions.Build("You are a phone support agent. Be brief.\n", brief: null)); + } + + [Fact] + public void TheBriefIsAppendedExactlyAsTheHostWroteIt() + { + const string brief = "Earlier summary, for context only.\n \"Ask first.\" "; + + Assert.Equal("Be brief.\n\n" + Strict + "\n\n" + brief, LiveInstructions.Build("Be brief.", brief)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveLog.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveLog.cs new file mode 100644 index 00000000..05aee1f6 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveLog.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Nodes; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// Recorded GPT-Live event logs, as the server sent them. + /// + internal static class LiveLog + { + /// Reads the events the server sent, in arrival order, as raw JSON. + public static IReadOnlyList Inbound(string fixture) + { + string path = Path.Combine(AppContext.BaseDirectory, "Vendors", "OpenAiLive", "Fixtures", fixture + ".jsonl"); + return [.. File.ReadLines(path) + .Where(line => line.Length > 0) + .Select(line => JsonNode.Parse(line)!) + .Where(entry => (string?)entry["dir"] == "in") + .Select(entry => entry["event"]!.ToJsonString())]; + } + + /// Finds the event of one type. + public static int IndexOf(IReadOnlyList events, string type, int nth = 1) + { + int seen = 0; + for (int index = 0; index < events.Count; index++) + { + if ((string?)JsonNode.Parse(events[index])!["type"] == type && ++seen == nth) + { + return index; + } + } + + throw new InvalidOperationException($"The log holds fewer than {nth} '{type}' events."); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveReplay.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveReplay.cs new file mode 100644 index 00000000..445dba4f --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveReplay.cs @@ -0,0 +1,44 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// A recorded log replayed through the reader and the ledger: its asks, the lines said ahead of each ask that it + /// writes before its words, its closed lines, and why it closed. + /// + internal sealed record LiveReplay(List<(string Id, string Words)> Asks, List> Before, List Lines, string? ClosedReason) + { + public static LiveReplay Of(string fixture) + { + LiveTranscriptLedger ledger = new(); + List<(string, string)> asks = []; + List> before = []; + List lines = []; + string? closed = null; + foreach (string json in LiveLog.Inbound(fixture)) + { + switch (LiveEventReader.Read(json)) + { + case LiveEvent.Transcript delta: + lines.AddRange(ledger.Add(delta)); + break; + case LiveEvent.Delegation delegation: + (IReadOnlyList closedLines, IReadOnlyList said, string words) = ledger.TakeForDelegation(); + lines.AddRange(closedLines); + asks.Add((delegation.Id, words)); + before.Add(said); + break; + case LiveEvent.Closed end: + lines.AddRange(ledger.Flush()); + closed = end.Reason; + break; + default: + break; + } + } + + return new LiveReplay(asks, before, lines, closed); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs new file mode 100644 index 00000000..e038e42d --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// What the GPT-Live call tests share. After the loop ends the call's session is closed, so these tests wait for + /// ConversationEnded instead of flushing: a hook's notices arrive in order, so every LineSpoken raised before the + /// end is already recorded. + /// + internal static class LiveSent + { + // The agent's policy reaches a terminal stage after its first turn, so the engine ends the conversation + // inside the first ask (as HookSessions.EndsAfterTheFirstTurnYaml does). + internal const string EndsAfterTheFirstAskYaml = """ + apiVersion: agentcore/v1 + guards: + afterFirst: { ">=": [ { var: turnIndex }, 1 ] } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "answer the caller" } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: afterFirst } ] } + - { id: done, agent: only, terminal: true } + """; + + // Every call opens with the session instructions (see TheCallTellsGptLiveToGreetTheCallerFirst). + internal static IEnumerable AfterTheGreeting(RunningLiveCall running) => + running.Sideband.Sent.Where(sent => (string?)sent["type"] != "session.instructions.append"); + + internal static bool IsCommentary(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend; + + internal static string? TypeOf(string json) => (string?)JsonNode.Parse(json)!["type"]; + + internal static async Task PushAndDrainAsync(RunningLiveCall running, IEnumerable events) + { + running.Sideband.Push(events); + await running.Sideband.WaitForDrainAsync(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs new file mode 100644 index 00000000..59e50a11 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs @@ -0,0 +1,169 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// A delegation closes the caller's words by arrival order, and lines close at a + /// speaker change or a delegation. An ask's words are the caller's latest request: the caller's line after the + /// last thing GPT-Live said itself; the exchanges before it ride ahead of the ask. Expected values are the recorded + /// logs and a real SIP call replayed under that rule. + /// + public sealed class LiveTranscriptLedgerTests + { + [Fact] + public void TheP1CallSplitsIntoSixLinesAndOneAsk() + { + LiveReplay replay = LiveReplay.Of("p1-a"); + + Assert.Equal( + [("item_ESevHlqkAJkwt1wThn5gW", "Can you check the status of my order? The order number is A four four seven one")], + replay.Asks); + Assert.Equal( + [new LiveLine(Speaker.Caller, "Hi there", 1200, 1800), new LiveLine(Speaker.Agent, "Hey! Thanks for calling Sole Fitness. How can I help?", 2000, 4800)], + Assert.Single(replay.Before)); + Assert.Equal( + [ + new LiveLine(Speaker.Caller, "Hi there", 1200, 1800), + new LiveLine(Speaker.Agent, "Hey! Thanks for calling Sole Fitness. How can I help?", 2000, 4800), + new LiveLine(Speaker.Caller, "Can you check the status of my order? The order number is A four four seven one", 5600, 10600), + new LiveLine(Speaker.Agent, "Sure. Checking that now. Okay. That order shipped on September 24 with UPS. It should arrive on Tuesday, September 29.", 10800, 19000), + new LiveLine(Speaker.Caller, "Okay. Thanks. That's all", 28600, 31200), + new LiveLine(Speaker.Agent, "You're welcome. Take care!", 31000, 32000), + ], + replay.Lines); + Assert.Equal("close_requested", replay.ClosedReason); + } + + // A correction is a second delegation that carries only the correction's words. + [Theory] + [InlineData("p3-drop-0", "item_ESexRCCGscju2z3LSZNCl", "What is the maximum speed of the Sole F63 treadmill?", "item_ESexZMJMkWUoowj5D7061", "Oh, wait, sorry. I meant the F80, not the F63")] + [InlineData("p3-stale-0", "item_ESexSsIl2XxGxcM1lsQFb", "What is the maximum speed of the Sole F63 treadmill", "item_ESexZiu0OmTOVioUO7wxZ", "Oh wait, sorry. I meant the F80 not the F63")] + public void ACorrectionIsASecondAskWithOnlyTheNewWords(string fixture, string firstId, string firstWords, string secondId, string secondWords) + { + LiveReplay replay = LiveReplay.Of(fixture); + + Assert.Equal([(firstId, firstWords), (secondId, secondWords)], replay.Asks); + Assert.All(replay.Before, Assert.Empty); + } + + // The real call: GPT-Live answered two questions itself (one wrongly), then delegated the third. + [Fact] + public void TheExchangesGptLiveHandledItselfRideAheadOfTheLatestRequest() + { + LiveTranscriptLedger ledger = new(); + Say(ledger, Speaker.Caller, " Uh, who are you", 1000, 2000); + Say(ledger, Speaker.Agent, " You're speaking with Sole Fitness's virtual assistant.", 2200, 4000); + Say(ledger, Speaker.Caller, " Um, do you know what day it is today", 5000, 7000); + Say(ledger, Speaker.Agent, " It's Wednesday.", 7200, 8000); + Say(ledger, Speaker.Caller, " Okay, uh please end the call now", 9000, 11000); + + (IReadOnlyList closed, IReadOnlyList before, string words) = ledger.TakeForDelegation(); + + Assert.Equal("Okay, uh please end the call now", words); + Assert.Equal( + [ + new LiveLine(Speaker.Caller, "Uh, who are you", 1000, 2000), + new LiveLine(Speaker.Agent, "You're speaking with Sole Fitness's virtual assistant.", 2200, 4000), + new LiveLine(Speaker.Caller, "Um, do you know what day it is today", 5000, 7000), + new LiveLine(Speaker.Agent, "It's Wednesday.", 7200, 8000), + ], + before); + Assert.Equal([new LiveLine(Speaker.Caller, "Okay, uh please end the call now", 9000, 11000)], closed); + } + + // After a delegation GPT-Live speaks its answer (and its filler): that is the turn's own reply, stored already. + [Fact] + public void WhatGptLiveSaysBeforeTheCallerSpeaksAgainIsTheLastAnswerNotTheFrontVoice() + { + LiveTranscriptLedger ledger = new(); + Say(ledger, Speaker.Caller, " Is the F80 in stock?", 0, 900); + _ = ledger.TakeForDelegation(); + Say(ledger, Speaker.Agent, " Checking that. Yes, it is in stock.", 1000, 3000); + Say(ledger, Speaker.Caller, " Thanks. Who am I talking to?", 3500, 5000); + Say(ledger, Speaker.Agent, " Sole Fitness's virtual assistant.", 5200, 6500); + Say(ledger, Speaker.Caller, " How much is it?", 7000, 8000); + + (_, IReadOnlyList before, string words) = ledger.TakeForDelegation(); + + Assert.Equal("How much is it?", words); + Assert.Equal( + [new LiveLine(Speaker.Caller, "Thanks. Who am I talking to?", 3500, 5000), new LiveLine(Speaker.Agent, "Sole Fitness's virtual assistant.", 5200, 6500)], + before); + } + + // Before the first delegation GPT-Live answered nothing for us: its greeting is its own. + [Fact] + public void TheGreetingBeforeTheFirstDelegationRidesAheadOfTheAsk() + { + LiveTranscriptLedger ledger = new(); + Say(ledger, Speaker.Agent, " Thanks for calling Sole Fitness.", 0, 1500); + Say(ledger, Speaker.Caller, " Where is my order?", 2000, 3000); + + (_, IReadOnlyList before, string words) = ledger.TakeForDelegation(); + + Assert.Equal("Where is my order?", words); + Assert.Equal([new LiveLine(Speaker.Agent, "Thanks for calling Sole Fitness.", 0, 1500)], before); + } + + // GPT-Live's words between the caller's request and the delegation are about the delegation itself. + [Fact] + public void WhatGptLiveSaidAfterTheLatestRequestIsNotPartOfTheAsk() + { + LiveTranscriptLedger ledger = new(); + Say(ledger, Speaker.Caller, " Hi", 0, 300); + Say(ledger, Speaker.Agent, " Hello!", 400, 800); + Say(ledger, Speaker.Caller, " Where is my order?", 1000, 2000); + Say(ledger, Speaker.Agent, " Let me check.", 2100, 2600); + + (_, IReadOnlyList before, string words) = ledger.TakeForDelegation(); + + Assert.Equal("Where is my order?", words); + Assert.Equal([new LiveLine(Speaker.Caller, "Hi", 0, 300), new LiveLine(Speaker.Agent, "Hello!", 400, 800)], before); + } + + [Fact] + public void TheP3DropCallSplitsIntoFourLines() + { + Assert.Equal( + [ + new LiveLine(Speaker.Caller, "What is the maximum speed of the Sole F63 treadmill?", 1200, 5200), + new LiveLine(Speaker.Agent, "Hmm, alright, I’ll go check that.", 5400, 7600), + new LiveLine(Speaker.Caller, "Oh, wait, sorry. I meant the F80, not the F63", 8600, 12800), + new LiveLine(Speaker.Agent, "Sure, I’ll check the F80. The Sole F80 goes up to 10.5 miles per hour.", 13000, 18600), + ], + LiveReplay.Of("p3-drop-0").Lines); + } + + [Fact] + public void ADelegationWithNoNewCallerWordsAsksWithNone() + { + LiveTranscriptLedger ledger = new(); + _ = ledger.Add(new LiveEvent.Transcript(Speaker.Caller, " Hello", 0, 200)); + _ = ledger.TakeForDelegation(); + _ = ledger.Add(new LiveEvent.Transcript(Speaker.Agent, " One moment.", 300, 900)); + + (_, IReadOnlyList before, string words) = ledger.TakeForDelegation(); + + Assert.Equal(string.Empty, words); + Assert.Empty(before); + } + + [Fact] + public void ADelegationClosesTheLineThatIsOpen() + { + LiveTranscriptLedger ledger = new(); + _ = ledger.Add(new LiveEvent.Transcript(Speaker.Caller, " Is it in stock?", 100, 900)); + + (IReadOnlyList closed, _, _) = ledger.TakeForDelegation(); + + Assert.Equal([new LiveLine(Speaker.Caller, "Is it in stock?", 100, 900)], closed); + Assert.Empty(ledger.Flush()); + } + + private static void Say(LiveTranscriptLedger ledger, Speaker speaker, string delta, int start, int end) => + _ = ledger.Add(new LiveEvent.Transcript(speaker, delta, start, end)); + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs new file mode 100644 index 00000000..610ecc90 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs @@ -0,0 +1,239 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; +using static AgentCore.AspNetCore.Tests.Vendors.OpenAiLive.LiveSent; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// When a call whose engine ended hangs up, over a recorded event log. + public sealed class OpenAiLiveCallEndTests + { + private static readonly DateTimeOffset Noon = RunningLiveCall.Noon; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The ack marks the start of speech, so the hang-up waits until GPT-Live's speech has been quiet for + // EndQuietWait. A caller's words do not hold the line up. Both the agent's last line and the caller's goodbye + // inside the quiet wait still reach the hooks after ConversationEnded: a spoken line is a record of the call. + // The clock stays short of CallAsks.OlderAskWait, so an end run from inside the ask's own + // turn would hang this test until its timeout. + [Fact(Timeout = 30_000)] + public async Task AnEngineEndHangsUpOnceTheAgentWentQuietAfterTheAck() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [hook], EndsAfterTheFirstAskYaml); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + int callerAgain = delegation + log.Skip(delegation).TakeWhile(json => TypeOf(json) != OpenAiLiveEvents.InputTranscriptDelta).Count(); + List answerSpoken = [.. log.Take(callerAgain).Skip(delegation + 1).Where(json => TypeOf(json) == OpenAiLiveEvents.OutputTranscriptDelta)]; + List callerSaysBye = [.. log.Skip(callerAgain).TakeWhile(json => TypeOf(json) != OpenAiLiveEvents.OutputTranscriptDelta)]; + + running.Sideband.Push(log.Take(delegation + 1)); + JsonObject commentary = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Sideband.Ack(commentary); + await PushAndDrainAsync(running, answerSpoken.Take(1)); + running.Time.Advance(TimeSpan.FromSeconds(1.2)); + await PushAndDrainAsync(running, answerSpoken.Skip(1).Take(1)); + running.Time.Advance(TimeSpan.FromSeconds(1.2)); + await PushAndDrainAsync(running, answerSpoken.Skip(2)); + bool hungUpWhileSpeaking = running.HungUp.Task.IsCompleted; + await PushAndDrainAsync(running, callerSaysBye); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + _ = await running.HungUp.Task; + await running.Loop; + _ = await hook.WaitForAsync(); + + Assert.False(hungUpWhileSpeaking); + Assert.True(running.Call.HasEnded); + Assert.Equal( + [ + (Speaker.Agent, "Sure. Checking that now. Okay. That order shipped on September 24 with UPS. It should arrive on Tuesday, September 29."), + (Speaker.Caller, "Okay. Thanks. That's all"), + ], + hook.Of().TakeLast(2).Select(line => (line.Speaker, line.Text))); + } + + // A second answer after the engine end (the fallback of a refused ask) starts the quiet wait again; the first + // answer's wait must not hang up in the middle of it. + [Fact(Timeout = 30_000)] + public async Task ASecondEndedAnswerReplacesTheQuietWaitOfTheFirst() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + JsonObject heard = new() { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " One more thing.", ["start_ms"] = 20000, ["end_ms"] = 21000 }; + JsonObject asked = new() { ["type"] = OpenAiLiveEvents.DelegationCreated, ["delegation"] = new JsonObject { ["id"] = "item_second" } }; + + running.Sideband.Push(log.Take(LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated) + 1)); + running.Sideband.Ack(await running.Sideband.WaitForSentAsync(IsCommentary)); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(TimeSpan.FromSeconds(1)); + running.Sideband.Push([heard.ToJsonString(), asked.ToJsonString()]); + running.Sideband.Ack(await running.Sideband.WaitForSentAsync(sent => IsCommentary(sent) && (string?)sent["delegation_id"] == "item_second")); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(TimeSpan.FromSeconds(1)); + running.Sideband.Push([log[LiveLog.IndexOf(log, "session.usage.updated")]]); + Task drained = running.Sideband.WaitForDrainAsync(); + Task first = await Task.WhenAny(drained, running.HungUp.Task); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + _ = await running.HungUp.Task; + + Assert.Same(drained, first); + } + + // An append GPT-Live never acks (an error, a lost frame) still ends the call. + [Fact(Timeout = 30_000)] + public async Task AnEngineEndWithNoAckHangsUpAfterTheAckWait() + { + using EventObservedLoggerProvider ackLate = new("AckLate"); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml, ackLate.CreateLogger("test")); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + + running.Sideband.Push(log.Take(LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated) + 1)); + _ = await running.Sideband.WaitForSentAsync(IsCommentary); + await running.Time.WaitForTimersAsync(Noon + OpenAiLiveCall.AckWait, 1); + bool hungUpEarly = running.HungUp.Task.IsCompleted; + running.Time.Advance(OpenAiLiveCall.AckWait); + _ = await running.HungUp.Task; + await running.Loop; + await ackLate.Observed; + + Assert.False(hungUpEarly); + Assert.True(running.Call.HasEnded); + } + + // Only the ack of the last piece starts the quiet wait. + [Fact(Timeout = 30_000)] + public async Task AMultiPieceAnswerEndsOnlyAfterItsLastPieceIsAcked() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml); + string words = string.Join(' ', Enumerable.Range(1, 120).Select(n => $"Sentence number {n:D3} is here.")); + JsonObject heard = new() { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " " + words, ["start_ms"] = 1000, ["end_ms"] = 2000 }; + JsonObject asked = new() { ["type"] = OpenAiLiveEvents.DelegationCreated, ["delegation"] = new JsonObject { ["id"] = "item_long" } }; + + running.Sideband.Push([heard.ToJsonString(), asked.ToJsonString()]); + _ = await running.Sideband.WaitForSentAsync(sent => IsCommentary(sent) && ((string?)sent["content"])!.EndsWith("Sentence number 120 is here.", StringComparison.Ordinal)); + List pieces = [.. running.Sideband.Sent.Where(IsCommentary)]; + running.Sideband.Ack(pieces[0]); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + bool hungUpOnTheFirstAck = running.HungUp.Task.IsCompleted; + running.Sideband.Ack(pieces[^1]); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + _ = await running.HungUp.Task; + + Assert.True(pieces.Count > 1); + Assert.False(hungUpOnTheFirstAck); + } + + // A caller who hangs up before the ack is not hung up again, and the call still finishes. + [Fact(Timeout = 30_000)] + public async Task ACallerHangUpBeforeTheAckFinishesTheCallWithNoHangUp() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + + running.Sideband.Push(log.Take(delegation + 1)); + _ = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Sideband.Push([log[LiveLog.IndexOf(log, OpenAiLiveEvents.Closed)]]); + await running.Loop; + + Assert.False(running.HungUp.Task.IsCompleted); + Assert.True(running.Call.HasEnded); + } + + // With no commentary to wait for, the hang-up is at once. The clock never moves, so no wait on a + // timer (CallAsks.OlderAskWait included) can be on this path. + [Fact(Timeout = 30_000)] + public async Task AnEngineEndWithNoCommentaryPendingHangsUpAtOnce() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), []); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + _ = running.Call.Session.EndConversation(ConversationEndReason.AgentCompleted); + + running.Sideband.Push([log[LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated)]]); + _ = await running.HungUp.Task; + await running.Loop; + + Assert.True(running.Call.HasEnded); + Assert.Equal([OpenAiLiveEvents.ThinkingAppend], AfterTheGreeting(running).Select(sent => (string?)sent["type"])); + } + + // An end from outside every delegation, such as an operator's end while the front + // voice chats, hangs up too, with the goodbye safety: once GPT-Live's speech has been quiet for EndQuietWait. + [Fact(Timeout = 30_000)] + public async Task AnEngineEndOutsideADelegationHangsUpOnceTheAgentWentQuiet() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), []); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + string agentSpeaks = log.First(json => TypeOf(json) == OpenAiLiveEvents.OutputTranscriptDelta); + + _ = running.Call.Session.EndConversation(ConversationEndReason.AgentCompleted); + await running.Time.WaitForTimersAsync(Noon + OpenAiLiveCall.EndQuietWait, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + running.Time.Advance(TimeSpan.FromSeconds(1.5)); + await PushAndDrainAsync(running, [agentSpeaks]); + running.Time.Advance(TimeSpan.FromSeconds(1)); + bool hungUpWhileSpeaking = running.HungUp.Task.IsCompleted; + running.Time.Advance(TimeSpan.FromSeconds(1)); + _ = await running.HungUp.Task; + await running.Loop; + + Assert.False(hungUpWhileSpeaking); + Assert.True(running.Call.HasEnded); + Assert.Empty(AfterTheGreeting(running)); + } + + // After such an end, a delegation's answer (the fallback of the refused ask) still follows the same rule: only the ack + // of its last piece starts the quiet wait, so the wait the end armed must not hang up before it. + [Fact(Timeout = 30_000)] + public async Task AnAnswerAfterAnEndOutsideADelegationHangsUpOnlyAfterItsAck() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), []); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + _ = running.Call.Session.EndConversation(ConversationEndReason.AgentCompleted); + await running.Time.WaitForTimersAsync(Noon + OpenAiLiveCall.EndQuietWait, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + + running.Sideband.Push(log.Take(LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated) + 1)); + JsonObject commentary = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + running.Sideband.Push([log[LiveLog.IndexOf(log, "session.usage.updated")]]); + Task drained = running.Sideband.WaitForDrainAsync(); + Assert.Same(drained, await Task.WhenAny(drained, running.HungUp.Task)); + running.Sideband.Ack(commentary); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + _ = await running.HungUp.Task; + } + + // The loop is held in its greeting send while the caller's words arrive and the end comes due, so its next + // receive and the end are both complete when it looks: the words are still a line of the call. + [Fact(Timeout = 30_000)] + public async Task WordsReceivedTogetherWithAnEndStillReachTheHooks() + { + RecordingHook hook = new(); + TaskCompletionSource held = new(TaskCreationOptions.RunContinuationsAsynchronously); + FakeSideband sideband = new() { SendsHeldUntil = held.Task }; + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [hook], sideband: sideband); + JsonObject heard = new() { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " Bye now.", ["start_ms"] = 1000, ["end_ms"] = 2000 }; + + _ = await sideband.WaitForSentAsync(_ => true); + sideband.Push([heard.ToJsonString()]); + _ = running.Call.Session.EndConversation(ConversationEndReason.AgentCompleted); + await running.Time.WaitForTimersAsync(Noon + OpenAiLiveCall.EndQuietWait, 1).WaitAsync(TimeSpan.FromSeconds(5), Ct); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + held.SetResult(); + _ = await running.HungUp.Task; + await running.Loop; + _ = await hook.WaitForAsync(); + + Assert.Contains((Speaker.Caller, "Bye now."), hook.Of().Select(line => (line.Speaker, line.Text))); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs new file mode 100644 index 00000000..23c793be --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs @@ -0,0 +1,190 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; +using static AgentCore.AspNetCore.Tests.Vendors.OpenAiLive.LiveSent; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// Calls over recorded event logs, with a real call core and a fake model. + public sealed class OpenAiLiveCallTests + { + private static readonly DateTimeOffset Noon = RunningLiveCall.Noon; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 30_000)] + public async Task TheP1CallSpeaksOurAnswerUnderItsDelegationAndEndsOnClose() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [hook]); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + + running.Sideband.Push(log.Take(delegation + 1)); + JsonObject commentary = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Sideband.Push(log.Skip(delegation + 1)); + await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal("item_ESevHlqkAJkwt1wThn5gW", (string?)commentary["delegation_id"]); + Assert.Equal("reply to Can you check the status of my order? The order number is A four four seven one", (string?)commentary["content"]); + Assert.Equal( + [ + (Speaker.Caller, "Hi there"), + (Speaker.Agent, "Hey! Thanks for calling Sole Fitness. How can I help?"), + (Speaker.Caller, "Can you check the status of my order? The order number is A four four seven one"), + (Speaker.Agent, "Sure. Checking that now. Okay. That order shipped on September 24 with UPS. It should arrive on Tuesday, September 29."), + (Speaker.Caller, "Okay. Thanks. That's all"), + (Speaker.Agent, "You're welcome. Take care!"), + ], + hook.Of().Select(line => (line.Speaker, line.Text))); + LineSpoken first = hook.Of()[0]; + Assert.Equal((Noon.AddMilliseconds(1200), Noon.AddMilliseconds(1800)), (first.StartedAt, first.EndedAt)); + Assert.Equal((ConversationEndReason.CallerHungUp, "close_requested"), (ended.Reason, ended.Call!.Cause)); + Assert.True(running.Sideband.Disposed); + } + + // Only the newer delegation is answered. + [Fact(Timeout = 30_000)] + public async Task ACorrectionLeavesTheOlderDelegationUnansweredAndAnswersTheNewer() + { + StallOnCueChatClient model = new() { HoldFirst = true }; + await using RunningLiveCall running = await RunningLiveCall.StartAsync(model, []); + IReadOnlyList log = LiveLog.Inbound("p3-drop-0"); + int first = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + int second = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated, nth: 2); + + running.Sideband.Push(log.Take(first + 1)); + await model.FirstEntered.Task; + running.Sideband.Push(log.Take(second + 1).Skip(first + 1)); + JsonObject commentary = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Sideband.Push(log.Skip(second + 1)); + await running.Loop; + + Assert.Equal("item_ESexZMJMkWUoowj5D7061", (string?)commentary["delegation_id"]); + // The newer ask is a resend that carries the older ask's words before its own. + Assert.Equal("reply to What is the maximum speed of the Sole F63 treadmill? Oh, wait, sorry. I meant the F80, not the F63", (string?)commentary["content"]); + Assert.DoesNotContain(running.Sideband.Sent, sent => (string?)sent["delegation_id"] == "item_ESexRCCGscju2z3LSZNCl" && IsCommentary(sent)); + } + + // Nothing goes out once the call ended: the socket may already be gone. + [Fact(Timeout = 30_000)] + public async Task AHangUpDuringAnAskSendsNoCommentary() + { + RecordingHook hook = new(); + StallOnCueChatClient model = new() { HoldFirst = true }; + await using RunningLiveCall running = await RunningLiveCall.StartAsync(model, [hook]); + IReadOnlyList log = LiveLog.Inbound("p2-strict-0"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + + running.Sideband.Push(log.Take(delegation + 1)); + await model.FirstEntered.Task; + running.Sideband.Push([log[LiveLog.IndexOf(log, OpenAiLiveEvents.Closed)]]); + await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.DoesNotContain(running.Sideband.Sent, IsCommentary); + Assert.Equal("close_requested", ended.Call!.Cause); + } + + // Tool progress is silent context for the open delegation. + [Fact(Timeout = 30_000)] + public async Task EachToolCallGoesOutAsThinkingBeforeTheAnswer() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new GatedToolCallingChatClient(), []); + IReadOnlyList log = LiveLog.Inbound("p2-strict-0"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + + running.Sideband.Push(log.Take(delegation + 1)); + _ = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Sideband.Push(log.Skip(delegation + 1)); + await running.Loop; + + Assert.Equal( + [OpenAiLiveEvents.ThinkingAppend, OpenAiLiveEvents.CommentaryAppend], + AfterTheGreeting(running).Select(sent => (string?)sent["type"])); + Assert.Equal("The backend is running the tool price_lookup. No answer yet.", (string?)AfterTheGreeting(running).First()["content"]); + } + + [Fact(Timeout = 30_000)] + public async Task ASidebandThatClosesWithNoSessionClosedEndsTheCallAsFaulted() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [hook]); + + running.Sideband.Push(LiveLog.Inbound("p1-a").Take(3)); + running.Sideband.Complete(); + await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal((ConversationEndReason.Faulted, "sideband_closed"), (ended.Reason, ended.Call!.Cause)); + } + + // A sideband that breaks mid-line still hands the line it was hearing to the hooks, as every other end does. + [Fact(Timeout = 30_000)] + public async Task ASidebandThatFailsMidLineStillRaisesTheOpenLine() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [hook]); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + int callerAsks = LiveLog.IndexOf(log, OpenAiLiveEvents.InputTranscriptDelta, nth: 3); + + running.Sideband.Push(log.Take(callerAsks)); + running.Sideband.Fail(new IOException("The socket was reset.")); + await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal( + [(Speaker.Caller, "Hi there"), (Speaker.Agent, "Hey! Thanks for calling Sole Fitness. How can I help?")], + hook.Of().Select(line => (line.Speaker, line.Text))); + Assert.Equal((ConversationEndReason.Faulted, "sideband_failed"), (ended.Reason, ended.Call!.Cause)); + } + + // GPT-Live waits for the caller's voice unless it is told to speak first (OpenAI's live-conversations guide): + // a session.instructions.append with no delegation, before anything else. + [Fact(Timeout = 30_000)] + public async Task TheCallTellsGptLiveToGreetTheCallerFirst() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml); + + JsonObject greet = await running.Sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.instructions.append"); + + Assert.Same(greet, running.Sideband.Sent[0]); + Assert.True(greet.ContainsKey("delegation_id")); + Assert.Null(greet["delegation_id"]); + Assert.False(string.IsNullOrWhiteSpace((string?)greet["content"])); + Assert.False(string.IsNullOrWhiteSpace((string?)greet["event_id"])); + } + + // A call whose conversation a newer call took is hung up, and says nothing. + [Fact(Timeout = 30_000)] + public async Task ACallThatLostItsConversationIsHungUpWithNoCommentary() + { + RecordingHook hook = new(); + using EventObservedLoggerProvider hungUpBecause = new("HungUpBecause"); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new StallOnCueChatClient(), [hook, new CallDecider(gate => gate.Accept("thread-9"))], logger: hungUpBecause.CreateLogger("test")); + await running.Harness.Sessions.CloseAsync("main", "thread-9", Ct); + PhoneCall newer = (await PhoneCall.AdmitAsync(running.Harness.Host, PhoneCallHarness.Offer("call-8"), Ct)).Call!; + await newer.StartAsync(Ct); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + + running.Sideband.Push(log.Take(delegation + 1)); + _ = await running.HungUp.Task; + running.Sideband.Push(log.Skip(delegation + 1)); + await running.Loop; + await newer.Session.FlushNoticesAsync(); + + Assert.True(running.Call.HasEnded); + Assert.DoesNotContain(running.Sideband.Sent, IsCommentary); + Assert.Empty(hook.Of()); + Assert.Contains("conversation_lost", hungUpBecause.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs new file mode 100644 index 00000000..0ee4e025 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The registry of live calls: how a call's run ends, and how the host's stop waits for it. + public sealed class OpenAiLiveCallsTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A fault in a call's run (here the sideband's dispose) is logged, never left unobserved. + [Fact(Timeout = 30_000)] + public async Task AFaultInACallsRunIsLogged() + { + using EventObservedLoggerProvider runFaulted = new("RunFaulted"); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), []); + using OpenAiLiveCalls calls = new(new ServiceCollection().BuildServiceProvider()); + FakeSideband sideband = new() { DisposeFault = new IOException("The socket was already gone.") }; + + calls.Run(await StartedCallAsync(harness, sideband, _ => Task.FromResult(true), runFaulted.CreateLogger("test"))); + await calls.StopAsync(Ct); + + Assert.Equal(1, runFaulted.Count); + Assert.True(sideband.Disposed); + } + + // The host's stop token bounds the wait for a hang-up that never returns. + [Fact(Timeout = 30_000)] + public async Task AStopWhoseTokenFiresWhileAHangUpHangsReturns() + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), []); + using OpenAiLiveCalls calls = new(new ServiceCollection().BuildServiceProvider()); + TaskCompletionSource hangUp = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource hangingUp = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CancellationTokenSource stop = new(); + calls.Run(await StartedCallAsync(harness, new FakeSideband(), _ => { hangingUp.TrySetResult(); return hangUp.Task; }, null)); + + Task stopping = calls.StopAsync(stop.Token); + await hangingUp.Task; + await stop.CancelAsync(); + Exception? stopped = await Record.ExceptionAsync(() => stopping.WaitAsync(TimeSpan.FromSeconds(10), Ct)); + _ = hangUp.TrySetResult(true); + + Assert.IsAssignableFrom(stopped); + } + + // The host's stop ends a live call the way a normal end does, so the line the caller was still + // speaking is closed and raised before the conversation ends. + [Fact(Timeout = 30_000)] + public async Task AStopRaisesTheLineStillOpen() + { + RecordingHook hook = new(); + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), [hook]); + using OpenAiLiveCalls calls = new(new ServiceCollection().BuildServiceProvider()); + FakeSideband sideband = new(); + JsonObject heard = new() { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " I still need", ["start_ms"] = 1000, ["end_ms"] = 2000 }; + calls.Run(await StartedCallAsync(harness, sideband, _ => Task.FromResult(true), null)); + + sideband.Push([heard.ToJsonString()]); + await sideband.WaitForDrainAsync(); + await calls.StopAsync(Ct); + ConversationEnded ended = await hook.WaitForAsync(); + _ = await hook.WaitForAsync(); + + Assert.Contains((Speaker.Caller, "I still need"), hook.Of().Select(line => (line.Speaker, line.Text))); + Assert.Equal((ConversationEndReason.Faulted, OpenAiLiveCall.ShutdownCause), (ended.Reason, ended.Call!.Cause)); + } + + private static async Task StartedCallAsync( + PhoneCallHarness harness, FakeSideband sideband, Func> hangUp, ILogger? logger) + { + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + return new OpenAiLiveCall(call, sideband, hangUp, logger ?? NullLogger.Instance); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs new file mode 100644 index 00000000..bb06f736 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using AgentCore.AspNetCore.Tests.Fakes; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// On a real SIP call GPT-Live told the caller it was Wednesday; it was Sunday. OpenAI's delegation guide gives + /// session-wide facts with session.instructions.append and delegation_id: null. The call opens with + /// the clock line the back office reads each turn, in its format and zone, when the entry's agent keeps + /// clock: on. + /// + public sealed class OpenAiLiveClockTests + { + private const string ClockOnYaml = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "answer the caller" } + entries: + main: + agent: only + """; + + private const string ClockOffYaml = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "answer the caller", clock: false } + entries: + main: + agent: only + """; + + private const string Greeting = "Begin the conversation now: greet the caller as your instructions say, then pause and listen."; + + // RunningLiveCall's clock stands at 2026-10-04 12:00 UTC, a Sunday. + [Fact(Timeout = 30_000)] + public async Task TheCallTellsGptLiveTheDateAndTimeBeforeTheGreeting() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], ClockOnYaml); + + _ = await running.Sideband.WaitForSentAsync(IsGreeting); + + JsonObject clock = running.Sideband.Sent[0]; + Assert.Equal("session.instructions.append", (string?)clock["type"]); + Assert.True(clock.ContainsKey("delegation_id")); + Assert.Null(clock["delegation_id"]); + Assert.Equal("Today is Sunday, 2026-10-04. The local time is 12:00, UTC (UTC+00:00).", (string?)clock["content"]); + Assert.True(IsGreeting(running.Sideband.Sent[1])); + Assert.NotEqual((string?)clock["event_id"], (string?)running.Sideband.Sent[1]["event_id"]); + } + + [Fact(Timeout = 30_000)] + public async Task AnEntryAgentWithClockOffTellsGptLiveNoTime() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], ClockOffYaml); + + JsonObject greeting = await running.Sideband.WaitForSentAsync(IsGreeting); + + Assert.Same(greeting, running.Sideband.Sent[0]); + } + + private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == Greeting; + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCredentialsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCredentialsTests.cs new file mode 100644 index 00000000..d0ec8ede --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCredentialsTests.cs @@ -0,0 +1,43 @@ +using AgentCore.Application.Secrets; +using AgentCore.AspNetCore.Vendors.OpenAiLive; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The openai-live credentials come from the secret resolver, and a bad signing secret fails at boot naming the secret, never its value. + public sealed class OpenAiLiveCredentialsTests + { + private const string ApiKey = "sk-test-key"; + + [Fact] + public async Task BothSecretsResolveAndTheSigningSecretIsDecoded() + { + MapSecretResolver secrets = new MapSecretResolver() + .With(KnownSecrets.OpenAiApiKeyName, ApiKey) + .With(KnownSecrets.OpenAiWebhookSecretName, "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"); + + OpenAiLiveCredentials credentials = await OpenAiLiveCredentials.ResolveAsync(secrets, TestContext.Current.CancellationToken); + + Assert.Equal(ApiKey, credentials.ApiKey); + Assert.Equal(Convert.FromBase64String("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"), credentials.WebhookKey); + } + + [Fact] + public async Task ABadSigningSecretFailsNamingTheSecretAndNotItsValue() + { + const string badValue = "whsec_not*base64*at-all"; + MapSecretResolver secrets = new MapSecretResolver() + .With(KnownSecrets.OpenAiApiKeyName, ApiKey) + .With(KnownSecrets.OpenAiWebhookSecretName, badValue); + + SecretResolutionException failure = await Assert.ThrowsAsync( + () => OpenAiLiveCredentials.ResolveAsync(secrets, TestContext.Current.CancellationToken)); + + Assert.Contains(KnownSecrets.OpenAiWebhookSecretName, failure.Message, StringComparison.Ordinal); + Assert.Contains(KnownSecrets.OpenAiWebhookSecretVariable, failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(badValue, failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("not*base64", failure.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs new file mode 100644 index 00000000..131a5942 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.BuiltIn; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Secrets; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// One recorded call, through the real route, the real call core, and the real engine. + public sealed class OpenAiLiveEndToEndTests + { + [Fact(Timeout = 30_000)] + public async Task TheRecordedP1CallRunsThroughTheRealRoute() + { + RecordingHook hook = new(); + InstructionsSeenChatClient model = new(new StallOnCueChatClient()); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync( + model, [hook, new CallDecider(gate => gate.Accept("cw_1_call_" + gate.CallId, "BRIEF-P1"))]); + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_p1")); + FakeSideband sideband = await host.FirstAttach.Task; + IReadOnlyList log = LiveLog.Inbound("p1-a"); + int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); + + sideband.Push(log.Take(delegation + 1)); + JsonObject commentary = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend); + sideband.Push(log.Skip(delegation + 1)); + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal("reply to Can you check the status of my order? The order number is A four four seven one", (string?)commentary["content"]); + Assert.Contains("BRIEF-P1", model.Instructions[^1], StringComparison.Ordinal); + Assert.Contains(CallBriefHook.FrontVoiceNote, model.Instructions[^1], StringComparison.Ordinal); + Assert.Equal( + [ + (Speaker.Caller, "Hi there"), + (Speaker.Agent, "Hey! Thanks for calling Sole Fitness. How can I help?"), + (Speaker.Caller, "Can you check the status of my order? The order number is A four four seven one"), + (Speaker.Agent, "Sure. Checking that now. Okay. That order shipped on September 24 with UPS. It should arrive on Tuesday, September 29."), + (Speaker.Caller, "Okay. Thanks. That's all"), + (Speaker.Agent, "You're welcome. Take care!"), + ], + hook.Of().Select(line => (line.Speaker, line.Text))); + Assert.Equal(("cw_1_call_rtc_p1", ConversationEndReason.CallerHungUp), (ended.Scope.ConversationId, ended.Reason)); + Assert.Equal(("rtc_p1", "close_requested"), (ended.Call!.CallId, ended.Call.Cause)); + } + + // No call outlives the host. + [Fact(Timeout = 30_000)] + public async Task AShutdownHangsUpEveryLiveCallAndEndsEachWithCauseShutdown() + { + RecordingHook hook = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [hook]); + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_a"), webhookId: "wh_a"); + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_b"), webhookId: "wh_b"); + await Poll.UntilAsync(() => + { + lock (host.Attached) + { + return host.Attached.Count == 2; + } + }); + + await host.StopAsync(); + ConversationEnded first = await hook.WaitForAsync(ended => ended.Call!.CallId == "rtc_a"); + ConversationEnded second = await hook.WaitForAsync(ended => ended.Call!.CallId == "rtc_b"); + + Assert.Equal(["/v1/live/sessions/rtc_a/hangup", "/v1/live/sessions/rtc_b/hangup"], host.Control.Requests.Select(seen => seen.Path).Where(path => path.EndsWith("/hangup", StringComparison.Ordinal)).Order()); + Assert.Equal( + [(ConversationEndReason.Faulted, "shutdown"), (ConversationEndReason.Faulted, "shutdown")], + [(first.Reason, first.Call!.Cause), (second.Reason, second.Call!.Cause)]); + Assert.NotEqual(first.Scope.ConversationId, second.Scope.ConversationId); + } + + // A missing webhook secret stops the host, not the first caller. + [Fact(Timeout = 30_000)] + public async Task AHostWithNoWebhookSecretFailsToStart() + { + if (Environment.GetEnvironmentVariable(KnownSecrets.OpenAiWebhookSecretVariable) is not null) + { + Assert.Skip("OPENAI_WEBHOOK_SECRET is set in this environment, so the resolver's fallback finds a secret."); + } + + SecretResolutionException failure = await Assert.ThrowsAsync( + () => OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [], new MapSecretResolver().With("openai-api-key", OpenAiLiveHost.ApiKey))); + + Assert.Contains("openai-webhook-secret", failure.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs new file mode 100644 index 00000000..61b90005 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; +using static AgentCore.AspNetCore.Tests.Vendors.OpenAiLive.LiveSent; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// A real SIP call (2026-10-04): GPT-Live answered "who are you" and "what day is it" itself, the second one + /// wrongly, then delegated "please end the call now". OpenAI's delegation guide: the delegation carries no text, + /// and the backend request is built from both transcripts and the saved history. The back office must read both + /// sides as the caller heard them, and answer only the latest request. + /// + public sealed class OpenAiLiveFrontVoiceTests + { + private const string LatestRequest = "Okay, uh please end the call now"; + + private static readonly (string Role, string? Author, string Text)[] SaidBefore = + [ + ("user", null, "Uh, who are you"), + ("assistant", FrontVoice.AuthorName, "You're speaking with Sole Fitness's virtual assistant."), + ("user", null, "Um, do you know what day it is today"), + ("assistant", FrontVoice.AuthorName, "It's Wednesday."), + ]; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 30_000)] + public async Task TheBackOfficeReadsBothSidesOfWhatGptLiveHandledThenOnlyTheLatestRequest() + { + StallOnCueChatClient model = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(model, []); + + JsonObject commentary = await AskAsync(running); + + Assert.Equal("reply to " + LatestRequest, (string?)commentary["content"]); + Assert.Equal([.. SaidBefore, ("user", null, LatestRequest)], Spoken(Assert.Single(model.Requests))); + } + + // The lines are words of the one delegated turn: one TurnStarted, one TurnCompleted, and the reply is the agent's. + [Fact(Timeout = 30_000)] + public async Task TheLinesRaiseNoTurnOfTheirOwnAndEveryLineIsSpokenOnce() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [hook]); + + _ = await AskAsync(running); + await EndAsync(running); + _ = await hook.WaitForAsync(); + + Assert.Equal(LatestRequest, Assert.Single(hook.Of()).UserText); + TurnCompleted completed = Assert.Single(hook.Of()); + Assert.Equal((LatestRequest, "reply to " + LatestRequest), (completed.UserText, completed.ReplyText)); + Assert.Equal( + [ + (Speaker.Caller, "Uh, who are you"), + (Speaker.Agent, "You're speaking with Sole Fitness's virtual assistant."), + (Speaker.Caller, "Um, do you know what day it is today"), + (Speaker.Agent, "It's Wednesday."), + (Speaker.Caller, LatestRequest), + ], + hook.Of().Select(line => (line.Speaker, line.Text))); + } + + [Fact(Timeout = 30_000)] + public async Task AfterAReloadTheConversationHoldsBothSidesInSpeechOrder() + { + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), []); + ConversationSession during = running.Call.Session; + + _ = await AskAsync(running); + await EndAsync(running); + ConversationSession reloaded = await running.Harness.Sessions.GetOrOpenAsync("main", running.Call.ConversationId, state: null, Ct); + _ = await reloaded.Ledger.OpenSessionAsync(Ct); + + Assert.NotSame(during, reloaded); + Assert.Equal([.. SaidBefore, ("user", null, LatestRequest), ("assistant", "only", "reply to " + LatestRequest)], Spoken(reloaded.Transcript)); + } + + private static async Task AskAsync(RunningLiveCall running) + { + running.Sideband.Push( + [ + Heard(OpenAiLiveEvents.InputTranscriptDelta, " Uh, who are you", 1000, 2000), + Heard(OpenAiLiveEvents.OutputTranscriptDelta, " You're speaking with Sole Fitness's", 2200, 3000), + Heard(OpenAiLiveEvents.OutputTranscriptDelta, " virtual assistant.", 3000, 4000), + Heard(OpenAiLiveEvents.InputTranscriptDelta, " Um, do you know what day it is today", 5000, 7000), + Heard(OpenAiLiveEvents.OutputTranscriptDelta, " It's Wednesday.", 7200, 8000), + Heard(OpenAiLiveEvents.InputTranscriptDelta, " Okay, uh please end the call now", 9000, 11000), + new JsonObject + { + ["type"] = OpenAiLiveEvents.DelegationCreated, + ["offset_ms"] = 10500, + ["delegation"] = new JsonObject { ["id"] = "item_end", ["type"] = "client", ["target"] = "backend" }, + }.ToJsonString(), + ]); + return await running.Sideband.WaitForSentAsync(IsCommentary); + } + + private static async Task EndAsync(RunningLiveCall running) + { + IReadOnlyList log = LiveLog.Inbound("p1-a"); + running.Sideband.Push([log[LiveLog.IndexOf(log, OpenAiLiveEvents.Closed)]]); + await running.Loop; + } + + private static string Heard(string type, string delta, int start, int end) => + new JsonObject { ["type"] = type, ["delta"] = delta, ["start_ms"] = start, ["end_ms"] = end }.ToJsonString(); + + private static List<(string Role, string? Author, string Text)> Spoken(IEnumerable messages) => + [.. messages.Where(message => message.Role != ChatRole.System).Select(message => (message.Role.Value, message.AuthorName, message.Text))]; + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs new file mode 100644 index 00000000..28e21202 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs @@ -0,0 +1,43 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.Vendors.OpenAiLive; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The live: block holds instructions, voice and model, and nothing else. + public sealed class OpenAiLiveSettingsTests + { + [Fact] + public void InstructionsAloneTakeTheDefaultVoiceAndModel() + { + Assert.Equal(new OpenAiLiveSettings("Be brief.", "marin", "gpt-live-1"), OpenAiLiveSettings.From(Conversation("live: { instructions: \"Be brief.\" }"))); + } + + [Fact] + public void AVoiceAndAModelAreTaken() + { + Assert.Equal( + new OpenAiLiveSettings("Be brief.", "cedar", "gpt-live-2"), + OpenAiLiveSettings.From(Conversation("live: { instructions: \"Be brief.\", voice: cedar, model: gpt-live-2 }"))); + } + + [Theory] + [InlineData("live: { voice: marin }", "/providers/conversation/live/instructions")] + [InlineData("live: { instructions: \"x\", webhookSecret: \"${secret:OPENAI_WEBHOOK_SECRET}\" }", "/providers/conversation/live/webhookSecret")] + [InlineData("answerSeconds: 5", "/providers/conversation/live")] + public void AWrongBlockFailsWithAPointer(string block, string errorPath) + { + ConfigurationLoadException failure = Assert.Throws(() => OpenAiLiveSettings.From(Conversation(block))); + + Assert.Equal(errorPath, failure.Errors[0].Pointer); + } + + private static ConversationProviderConfiguration Conversation(string block) + { + return ConfigurationLoader.LoadYaml( + "apiVersion: agentcore/v1\nagents:\n items:\n - { id: only, instructions: \"ok\" }\nentries:\n main:\n agent: only\n" + + "providers:\n conversation:\n kind: openai-live\n " + block + "\n").Providers!.Conversation!; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs new file mode 100644 index 00000000..b35aa869 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// What the webhook and the registry leave behind when a call faults part way: no claimed conversation, no live call, no remembered webhook id. + public sealed class OpenAiLiveWebhookCleanupTests + { + private const string Thread = "thread-9"; + + private static bool Hangup(RecordingLiveControl.Seen seen, string callId) => seen.Path == $"/v1/live/sessions/{callId}/hangup"; + + // The attach is the last step before the call runs. + [Fact(Timeout = 30_000)] + public async Task AnAttachThatFailsHangsUpEndsTheCallAndFreesItsConversation() + { + RecordingHook hook = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync( + new StallOnCueChatClient(), + [hook, new CallDecider(gate => gate.Accept(Thread))], + attachFault: attach => attach.CallId == "rtc_1" ? new IOException("no sideband") : null); + + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_1"), webhookId: "wh_1"); + ConversationEnded ended = await hook.WaitForAsync(); + HttpResponseMessage second = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_2"), webhookId: "wh_2"); + _ = await host.FirstAttach.Task; + + Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_1")); + Assert.Equal(("rtc_1", OpenAiLiveCall.AttachFailedCause), (ended.Call!.CallId, ended.Call.Cause)); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Single(host.Attached); + } + + // StartAsync opens the store after the accept landed: a store that is down must not leave GPT-Live a call with no sideband. + [Fact(Timeout = 30_000)] + public async Task AStartThatFailsAfterTheAcceptHangsUpAndFreesTheConversation() + { + FailingCreateStore store = new(); + string yaml = OpenAiLiveHost.LiveYaml.Replace(" llm:\n", " conversations: { kind: test }\n llm:\n", StringComparison.Ordinal); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync( + new StallOnCueChatClient(), + [new CallDecider(gate => gate.Accept(Thread))], + yaml: yaml, + configure: options => options.UseConversationStores(store)); + + HttpResponseMessage first = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_1"), webhookId: "wh_1"); + store.Failing = false; + HttpResponseMessage second = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_2"), webhookId: "wh_2"); + _ = await host.FirstAttach.Task; + + Assert.Equal(HttpStatusCode.InternalServerError, first.StatusCode); + Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_1")); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Single(host.Attached); + } + + // A webhook id counts as handled only after a 2xx; OpenAI redelivers a 500 under the same id. + [Fact(Timeout = 30_000)] + public async Task ARequestThatEndsIn500LetsTheRedeliveryUnderTheSameWebhookIdThrough() + { + RecordingHook hook = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [hook]); + int accepts = 0; + host.Control.Answer = path => path.EndsWith("/accept", StringComparison.Ordinal) && Interlocked.Increment(ref accepts) == 1 + ? throw new TaskCanceledException("the deadline passed") + : null; + + HttpResponseMessage first = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_1"), webhookId: "wh_1"); + HttpResponseMessage redelivered = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_1"), webhookId: "wh_1"); + _ = await host.FirstAttach.Task; + CallStarted started = await hook.WaitForAsync(); + + Assert.Equal((HttpStatusCode.InternalServerError, HttpStatusCode.OK), (first.StatusCode, redelivered.StatusCode)); + Assert.Equal("rtc_1", started.CallId); + Assert.Single(host.Attached); + } + + // A host token that is already spent must not skip the end of a call or leave its loop reading. + [Fact(Timeout = 30_000)] + public async Task AStopWithASpentTokenStillEndsEveryRunningCallAndStopsItsLoop() + { + RecordingHook hook = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [hook]); + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_a"), webhookId: "wh_a"); + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_b"), webhookId: "wh_b"); + + Exception? stopped = await Record.ExceptionAsync( + () => host.Services.GetRequiredService().StopAsync(new CancellationToken(canceled: true))); + ConversationEnded endedA = await hook.WaitForAsync(notice => notice.Call?.CallId == "rtc_a"); + ConversationEnded endedB = await hook.WaitForAsync(notice => notice.Call?.CallId == "rtc_b"); + await Poll.UntilAsync(() => host.Attached.All(sideband => sideband.Disposed)); + + Assert.True(stopped is null or OperationCanceledException); + Assert.Equal(OpenAiLiveCall.ShutdownCause, endedA.Call!.Cause); + Assert.Equal(OpenAiLiveCall.ShutdownCause, endedB.Call!.Cause); + Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_a")); + Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_b")); + } + + // A call that reaches the registry after the host began to stop is ended, not run. + [Fact(Timeout = 30_000)] + public async Task ACallThatArrivesAfterTheStopBeganIsHungUpAndEndedWithCauseShutdown() + { + RecordingHook hook = new(); + using SemaphoreSlim attaching = new(0); + using SemaphoreSlim proceed = new(0); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync( + new StallOnCueChatClient(), + [hook], + attachFault: offered => + { + _ = attaching.Release(); + _ = proceed.Wait(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken); + return null; + }); + + Task posting = host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_late")); + await attaching.WaitAsync(TestContext.Current.CancellationToken); + await host.Services.GetRequiredService().StopAsync(TestContext.Current.CancellationToken); + _ = proceed.Release(); + _ = await posting; + ConversationEnded ended = await hook.WaitForAsync(); + await Poll.UntilAsync(() => host.Attached.All(sideband => sideband.Disposed)); + + Assert.Equal(OpenAiLiveCall.ShutdownCause, ended.Call!.Cause); + Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_late")); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs new file mode 100644 index 00000000..f36d7ced --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Text.Json.Nodes; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The incoming-call webhook through the real /v1/{entry}/call route. + public sealed class OpenAiLiveWebhookTests + { + private const string Brief = "Earlier summary, for context only.\n\"Ask first.\""; + + // The accept payload a real call accepted, word for word. + private const string Strict = + "You know no product facts yourself. For any question about products, specifications, orders," + + " parts, or policies, delegate to the backend and wait for its answer. Never guess a fact." + + " You cannot end the call yourself: when the caller wants to end or hang up the call, delegate that" + + " to the backend, and say goodbye with its answer."; + + [Fact(Timeout = 30_000)] + public async Task AnAcceptedCallIsAcceptedWithItsInstructionsVoiceAndClientDelegationThenAttached() + { + RecordingHook hook = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync( + new StallOnCueChatClient(), [hook, new CallDecider(gate => gate.Accept("cw_1_call_" + gate.CallId, Brief))]); + + HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); + _ = await host.FirstAttach.Task; + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + RecordingLiveControl.Seen accept = Assert.Single(host.Control.Requests); + Assert.Equal(("POST", "/v1/live/sessions/rtc_123/accept", "Bearer sk-test"), (accept.Method, accept.Path, accept.Authorization)); + // A real call refused anything else: "Invalid Live SIP accept payload: session must be the only field." + Assert.Equal(["session"], accept.Body!.AsObject().Select(field => field.Key)); + JsonNode session = accept.Body["session"]!; + Assert.Equal("Be brief.\n\n" + Strict + "\n\n" + Brief, (string?)session["instructions"]); + Assert.Equal("marin", (string?)session["audio"]!["output"]!["voice"]); + Assert.Equal("gpt-live-1", (string?)session["model"]); + Assert.Equal("client", (string?)session["delegation"]!["type"]); + CallStarted started = await hook.WaitForAsync(); + Assert.Equal(("rtc_123", "+15550100", "openai-live", "cw_1_call_rtc_123"), (started.CallId, started.From, started.Transport, started.Scope.ConversationId)); + } + + [Fact(Timeout = 30_000)] + public async Task ARejectingHookRejectsTheCallWithItsSipCode() + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [new CallDecider(gate => gate.Reject(CallRefusal.Busy))]); + + HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + RecordingLiveControl.Seen reject = Assert.Single(host.Control.Requests); + Assert.Equal("/v1/live/sessions/rtc_123/reject", reject.Path); + Assert.Equal(486, (int?)reject.Body!["status_code"]); + Assert.Empty(host.Attached); + } + + // A hook that throws fails closed. + [Fact(Timeout = 30_000)] + public async Task AHookThatThrowsRejectsTheCallAsUnavailable() + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [new CallDecider(_ => throw new InvalidOperationException("crm down"))]); + + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); + + Assert.Equal(503, (int?)Assert.Single(host.Control.Requests).Body!["status_code"]); + } + + [Fact(Timeout = 30_000)] + public async Task ABadSignatureIsRefusedBeforeAnyHookRuns() + { + bool asked = false; + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [new CallDecider(_ => asked = true)]); + + HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), secret: "whsec_" + Convert.ToBase64String(new byte[24])); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.False(asked); + Assert.Empty(host.Control.Requests); + } + + [Fact(Timeout = 30_000)] + public async Task AStaleTimestampIsRefused() + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), []); + + HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), signedAt: DateTimeOffset.UtcNow.AddMinutes(-6)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // One process never admits one call twice. + [Fact(Timeout = 30_000)] + public async Task ARepeatedWebhookForTheSameCallIsAcceptedOnce() + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), []); + + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), webhookId: "wh_1"); + _ = await host.FirstAttach.Task; + HttpResponseMessage repeated = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), webhookId: "wh_1"); + HttpResponseMessage redelivered = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), webhookId: "wh_2"); + + Assert.Equal((HttpStatusCode.OK, HttpStatusCode.OK), (repeated.StatusCode, redelivered.StatusCode)); + Assert.Single(host.Control.Requests); + Assert.Single(host.Attached); + } + + [Fact(Timeout = 30_000)] + public async Task AnEventThatIsNotAnIncomingCallIsAcknowledgedAndIgnored() + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), []); + + HttpResponseMessage response = await host.PostWebhookAsync("""{"type":"realtime.call.ended","data":{"call_id":"rtc_123"}}"""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(host.Control.Requests); + } + + // Another process already answered; this one lets go with no call notice. + [Fact(Timeout = 30_000)] + public async Task ADecisionAlreadyMadeLetsTheCallGoWithNoCallNotice() + { + RecordingHook hook = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [hook]); + host.Control.Answer = path => path.EndsWith("/accept", StringComparison.Ordinal) + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("""{"error":{"code":"decision_already_made"}}""") } + : null; + + HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); + await host.Services.GetRequiredService().Hooks.Notices.FlushAsync("rtc_123"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(host.Attached); + Assert.Empty(hook.Of()); + Assert.Empty(hook.Of()); + } + + // Every admitted call reaches StartAsync or AbandonAsync: a call whose accept never lands frees its conversation. + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task AnAcceptThatFailsFreesTheConversationForTheNextCall(bool throwing) + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync( + new StallOnCueChatClient(), [new CallDecider(gate => gate.Accept("thread-9"))]); + host.Control.Answer = path => path == "/v1/live/sessions/rtc_1/accept" + ? throwing ? throw new TaskCanceledException("the deadline passed") : new HttpResponseMessage(HttpStatusCode.InternalServerError) + : null; + + _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_1"), webhookId: "wh_1"); + HttpResponseMessage second = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_2"), webhookId: "wh_2"); + _ = await host.FirstAttach.Task; + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal( + ["/v1/live/sessions/rtc_1/accept", "/v1/live/sessions/rtc_2/accept"], + host.Control.Requests.Select(seen => seen.Path).Where(path => path.EndsWith("/accept", StringComparison.Ordinal))); + Assert.Single(host.Attached); + + // A thrown accept is unknown, so the call is hung up; a refused one is rejected, so the caller is not left ringing. + RecordingLiveControl.Seen[] first = [.. host.Control.Requests.Where(seen => seen.Path.StartsWith("/v1/live/sessions/rtc_1/", StringComparison.Ordinal) && !seen.Path.EndsWith("/accept", StringComparison.Ordinal))]; + Assert.Equal(throwing ? "/v1/live/sessions/rtc_1/hangup" : "/v1/live/sessions/rtc_1/reject", Assert.Single(first).Path); + if (!throwing) + { + Assert.Equal(503, (int?)first[0].Body!["status_code"]); + } + } + + // The adapter reads the live: block at boot, so a misspelt key fails the start and not the first caller. + [Fact(Timeout = 30_000)] + public async Task AnUnknownLiveKeyFailsTheHostStart() + { + string yaml = OpenAiLiveHost.LiveYaml.Replace("instructions: \"Be brief.\"", "instructions: \"Be brief.\"\n webhookSecret: x", StringComparison.Ordinal); + + ConfigurationLoadException failure = await Assert.ThrowsAsync( + () => OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [], yaml: yaml)); + + Assert.Contains("webhookSecret", failure.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs new file mode 100644 index 00000000..88ed67cf --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs @@ -0,0 +1,56 @@ +using System.Text; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// The incoming-call webhook as assumes it (the OpenAI SIP guide's shape). These tests + /// pin the parser to that shape, so a correction shows up as a failing test. + /// + public sealed class OpenAiLiveWireTests + { + internal const string IncomingCall = """ + {"object":"event","id":"evt_1","type":"realtime.call.incoming","created_at":1790500000, + "data":{"call_id":"rtc_123","sip_headers":[ + {"name":"From","value":"\"Caller\" ;tag=1"}, + {"name":"To","value":""}, + {"name":"Diversion","value":";reason=unconditional"}]}} + """; + + [Fact] + public void AnIncomingCallNamesItsIdTheTwoNumbersAndEveryHeader() + { + LiveIncomingCall call = OpenAiLiveWire.ReadIncomingCall(Encoding.UTF8.GetBytes(IncomingCall))!; + + Assert.Equal(("rtc_123", "+15550100", "+15550199"), (call.CallId, call.From, call.To)); + Assert.Equal(";reason=unconditional", call.Headers["diversion"]); + } + + // The GPT-Live docs name live.transport.incoming, the SIP guide realtime.call.incoming. + [Fact] + public void TheOtherDocumentedEventNameAndASessionIdAreReadToo() + { + const string body = """{"type":"live.transport.incoming","data":{"session_id":"live_9","sip_headers":[]}}"""; + + Assert.Equal("live_9", OpenAiLiveWire.ReadIncomingCall(Encoding.UTF8.GetBytes(body))!.CallId); + } + + [Fact] + public void AnotherEventIsNotACall() + { + Assert.Null(OpenAiLiveWire.ReadIncomingCall(Encoding.UTF8.GetBytes("""{"type":"realtime.call.ended","data":{"call_id":"rtc_123"}}"""))); + } + + [Theory] + [InlineData("\"Caller\" ;tag=1", "+15550100")] + [InlineData("", "+15550100")] + [InlineData("sip:anonymous@anonymous.invalid", "anonymous")] + [InlineData("garbage", null)] + public void TheUserPartOfASipAddressIsTheNumber(string header, string? number) + { + Assert.Equal(number, OpenAiLiveWire.SipUser(header)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs new file mode 100644 index 00000000..fa0dbed1 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs @@ -0,0 +1,86 @@ +using System.Text; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// The Standard Webhooks signature scheme (standardwebhooks.com; github.com/standard-webhooks/standard-webhooks, + /// spec/standard-webhooks.md, "Verifying webhook authenticity"): HMAC-SHA256 over "{id}.{timestamp}.{body}" with + /// the base64 key after "whsec_", sent as "v1,{base64}", space-separated. The vector is the spec's reference-library + /// example; recomputed with Python's hmac on 2026-10-03. + /// + public sealed class StandardWebhookSignatureTests + { + private const string Secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + + private const string Id = "msg_p5jXN8AQM9LWM0D4loKWxJek"; + + private const string Timestamp = "1614265330"; + + private const string Signature = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="; + + private static readonly byte[] Body = Encoding.UTF8.GetBytes("{\"test\": 2432232314}"); + + private static readonly DateTimeOffset SignedAt = DateTimeOffset.FromUnixTimeSeconds(1614265330); + + private static byte[] Key => StandardWebhookSignature.KeyOf(Secret); + + [Fact] + public void TheSpecsVectorVerifies() + { + Assert.True(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, Signature, SignedAt)); + } + + [Fact] + public void AChangedBodyFails() + { + Assert.False(StandardWebhookSignature.Verify(Key, Id, Timestamp, Encoding.UTF8.GetBytes("{\"test\": 2432232315}"), Signature, SignedAt)); + } + + [Fact] + public void ATimestampOutsideFiveMinutesFails() + { + Assert.False(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, Signature, SignedAt.AddMinutes(6))); + Assert.True(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, Signature, SignedAt.AddMinutes(4))); + } + + [Fact] + public void ATimestampMoreThanFiveMinutesInTheFutureFails() + { + Assert.False(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, Signature, SignedAt.AddMinutes(-6))); + Assert.True(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, Signature, SignedAt.AddMinutes(-4))); + } + + // The spec allows several signatures (key rotation); any one valid "v1" passes. + [Fact] + public void OneValidSignatureAmongSeveralPasses() + { + Assert.True(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, "v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= " + Signature, SignedAt)); + Assert.False(StandardWebhookSignature.Verify(Key, Id, Timestamp, Body, "v2," + Signature[3..], SignedAt)); + } + + // A webhook endpoint is public, so any header value can arrive; each must be a refusal, never an exception. + [Theory] + [InlineData(Id, Timestamp, "v1,!!!")] + [InlineData(Id, Timestamp, "v1,")] + [InlineData(Id, Timestamp, "v2,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=")] + [InlineData(Id, Timestamp, null)] + [InlineData(Id, null, Signature)] + [InlineData(Id, "not-a-number", Signature)] + [InlineData(Id, "9223372036854775807", Signature)] + [InlineData(null, Timestamp, Signature)] + public void AMalformedOrMissingHeaderFailsWithoutThrowing(string? id, string? timestamp, string? signatures) + { + Assert.False(StandardWebhookSignature.Verify(Key, id, timestamp, Body, signatures, SignedAt)); + } + + [Theory] + [InlineData("whsec_")] + [InlineData("whsec_!!!")] + public void ASecretThatHoldsNoUsableKeyFails(string secret) + { + Assert.Throws(() => StandardWebhookSignature.KeyOf(secret)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs new file mode 100644 index 00000000..880c472a --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs @@ -0,0 +1,45 @@ +using System.Net.WebSockets; +using System.Text; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// The sideband over a real socket: the bearer key, a fragmented message, and the close. + public sealed class WebSocketSidebandTests + { + [Fact(Timeout = 30_000)] + public async Task AttachSendsTheKeyAndReadsAFragmentedMessageWhole() + { + string? authorization = null; + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); + _ = builder.Logging.ClearProviders(); + await using WebApplication app = builder.Build(); + _ = app.UseWebSockets(); + _ = app.Map("/v1/live/sessions/rtc_1/attach", async (HttpContext http) => + { + authorization = http.Request.Headers.Authorization; + using WebSocket socket = await http.WebSockets.AcceptWebSocketAsync(); + await socket.SendAsync(Encoding.UTF8.GetBytes("{\"type\":\"session."), WebSocketMessageType.Text, endOfMessage: false, CancellationToken.None); + await socket.SendAsync(Encoding.UTF8.GetBytes("closed\"}"), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); + }); + await app.StartAsync(TestContext.Current.CancellationToken); + Uri address = new(app.Services.GetRequiredService().Features.Get()!.Addresses.Single() + "/"); + + await using ILiveSideband sideband = await WebSocketSideband.ConnectAsync(new LiveAttach("rtc_1", "sk-test", address), TestContext.Current.CancellationToken); + + Assert.Equal("{\"type\":\"session.closed\"}", await sideband.ReceiveAsync(TestContext.Current.CancellationToken)); + Assert.Null(await sideband.ReceiveAsync(TestContext.Current.CancellationToken)); + Assert.Equal("Bearer sk-test", authorization); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs index 2cf95f29..622c12d8 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs @@ -1,11 +1,11 @@ using System.Net.WebSockets; using System.Text; using System.Text.Json.Nodes; -using AgentCore.Application.Runtime; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay { @@ -18,7 +18,7 @@ public sealed class TelnyxRelayBargeInGuardTests [Fact(Timeout = 30_000)] public async Task AnInterruptDuringAReply_RecordsWhatTheCallerHeard() { - // The vendor measured both values, so nothing here is estimated. D28 and item 6a. + // The vendor measured both values, so nothing here is estimated. // // BlockingChatClient's gate ignores cancellation, so a defect that left the turn // machinery stuck would hang this test rather than fail it red. The ten-second deadline diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs index 420aefeb..a204b525 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs @@ -1,8 +1,8 @@ -using AgentCore.Application.Runtime; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay { @@ -12,8 +12,8 @@ namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay /// public sealed class TelnyxRelayBargeInRecoveryTests { - // Plan B1 (agent_activity.py:2774-2783): a second final prompt interrupts turn one, which keeps the text - // that reached the relay (design section 3), and turn two answers. Nothing is held. + // LiveKit's agent_activity.py:2774-2783: a second final prompt interrupts turn one, which keeps the text + // that reached the relay, and turn two answers. Nothing is held. [Fact(Timeout = 30_000)] public async Task ASecondFinalPromptDuringATurn_InterruptsTheFirstAndTheSecondAnswers() { @@ -78,9 +78,8 @@ public async Task ASecondFinalPromptDuringATurn_InterruptsTheFirstAndTheSecondAn [Fact(Timeout = 30_000)] public async Task ATurnAfterABargeIn_AnswersNormally() { - // Item 6a is half proved by AfterAnInterrupt_NoFurtherTextFrameReachesTheRelay: a barge-in - // ends one turn cleanly. The other half — the conversation itself goes on — needs its own proof, - // because nothing else here shows the caller can speak again and get a real answer rather + // AfterAnInterrupt_NoFurtherTextFrameReachesTheRelay proves a barge-in ends one turn cleanly. The conversation + // itself must also go on: nothing else here shows the caller can speak again and get a real answer rather // than silence or a leftover interrupted reply. using BlockingChatClient reply = new("hello there caller"); EventObservedLoggerProvider capture = new("InterruptReceived"); @@ -138,7 +137,7 @@ public async Task ATurnAfterABargeIn_AnswersNormally() Assert.Null(session!.LastTurn!.Cut); } - // Owner ruling 2026-09-24: once a later turn has started, an earlier turn's reply never changes. Turn one's + // Once a later turn has started, an earlier turn's reply never changes. Turn one's // reply is all on the wire and the relay is still playing it; turn two has streamed only content with no // words. A barge-in then reports what the caller heard of turn one, too late: the engine refuses the cut, // the refusal is logged, turn one keeps its reply, and turn two speaks on. diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs index 2df38fc1..2514965c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs @@ -1,6 +1,6 @@ -using AgentCore.Application.Runtime; using AgentCore.Domain; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs new file mode 100644 index 00000000..77c95102 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs @@ -0,0 +1,200 @@ +using System.Net.WebSockets; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// A relay call goes through the call gate, the call notices, and the brief. + public sealed class TelnyxRelayCallGateTests + { + private static readonly string StoreYaml = TelnyxRelayTurnTests.PolicyYaml.Replace(" llm:\n", " conversations: { kind: test }\n llm:\n", StringComparison.Ordinal); + + // With no hook, the conversation id is unchanged. + [Fact(Timeout = 30_000)] + public async Task WithNoHookARelayCallKeepsItsConversationIdAndStartsAsACall() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(hook)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "logical-7")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + _ = await relay.ReadTextFramesUntilLastAsync(); + + Assert.NotNull(await host.FindSessionAsync("logical-7")); + CallStarted started = await hook.WaitForAsync(); + Assert.Equal(("logical-7", "+13122010094", "+13122123456", "telnyx-relay"), (started.CallId, started.From, started.To, started.Transport)); + } + + [Fact(Timeout = 30_000)] + public async Task AHookThatDeclinesTheCallClosesTheSocketAndOpensNothing() + { + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(new CallDecider(gate => gate.Reject(CallRefusal.Declined)))); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + + Assert.Equal(((WebSocketCloseStatus?)WebSocketCloseStatus.PolicyViolation, (string?)"the call was declined"), await relay.ReadCloseAsync()); + Assert.Null(await host.FindSessionAsync("conversation-one")); + } + + // A refusal other than busy or declined names the call as one that could not be taken now. + [Fact(Timeout = 30_000)] + public async Task AHookThatRejectsTheCallAsUnavailableClosesTheSocketSayingSo() + { + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(new CallDecider(gate => gate.Reject(CallRefusal.Unavailable)))); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + + Assert.Equal(((WebSocketCloseStatus?)WebSocketCloseStatus.PolicyViolation, (string?)"the call could not be taken now"), await relay.ReadCloseAsync()); + Assert.Null(await host.FindSessionAsync("conversation-one")); + } + + [Fact(Timeout = 30_000)] + public async Task AHookThatAcceptsUnderItsOwnIdRunsTheConversationThere() + { + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(new CallDecider(gate => gate.Accept("thread-1")))); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + _ = await relay.ReadTextFramesUntilLastAsync(); + + Assert.NotNull(await host.FindSessionAsync("thread-1")); + Assert.Null(await host.FindSessionAsync("conversation-one")); + } + + [Fact(Timeout = 30_000)] + public async Task AHangUpEndsTheCallWithItsIdAndNoCause() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(hook)); + FakeRelayClient relay = await host.ConnectAsync(); + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + _ = await relay.ReadTextFramesUntilLastAsync(); + + await relay.DisposeAsync(); + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal(ConversationEndReason.CallerHungUp, ended.Reason); + Assert.Equal(("conversation-one", (string?)null), (ended.Call!.CallId, ended.Call.Cause)); + } + + // A relay call's brief reaches the engine too. + [Fact(Timeout = 30_000)] + public async Task TheBriefOfARelayCallReachesTheEngine() + { + using FragmentingChatClient inner = new("hello"); + InstructionsSeenChatClient reply = new(inner); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartThroughConversationSeamAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + options => options.UseConversation(new TelnyxRelayConversationAdapter()).UseHooks(new CallDecider(gate => gate.Accept("thread-2", "BRIEF-X")))); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + _ = await relay.ReadTextFramesUntilLastAsync(); + + Assert.Contains("BRIEF-X", reply.Instructions[0], StringComparison.Ordinal); + } + + // Two live calls never share a conversation. A second socket for another call on the same conversation is + // refused, and the first goes on. + [Fact(Timeout = 30_000)] + public async Task ASecondSocketForAnotherCallOnTheSameConversationIsRefusedAsHeldByAnotherCallAndTheFirstGoesOn() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(hook, new CallDecider(gate => gate.Accept("thread-1")))); + await using FakeRelayClient first = await host.ConnectAsync(); + await using FakeRelayClient second = await host.ConnectAsync(); + await first.SendAsync(RelayFrames.Setup(conversationSessionId: "logical-7")); + _ = await hook.WaitForAsync(); + + await second.SendAsync(RelayFrames.Setup(conversationSessionId: "logical-8")); + (WebSocketCloseStatus? Status, string? Description) refused = await second.ReadCloseAsync(); + await first.SendAsync(RelayFrames.Prompt("hi", last: true)); + + Assert.Equal((WebSocketCloseStatus.PolicyViolation, "the conversation is held by another call"), refused); + Assert.Equal("hello", string.Concat(await first.ReadTextFramesUntilLastAsync())); + } + + // PhoneCall.AdmitAsync: every admitted call reaches StartAsync or AbandonAsync, so a store that is down at the + // start leaves the id free for the next call. + [Fact(Timeout = 30_000)] + public async Task AStartThatFaultsFreesTheConversationForTheNextCall() + { + FailingCreateStore store = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(StoreYaml, reply, options => options.UseConversationStores(store)); + await using FakeRelayClient first = await host.ConnectAsync(); + await first.SendAsync(RelayFrames.Setup()); + (WebSocketCloseStatus? status, _) = await first.ReadCloseAsync(); + store.Failing = false; + + await using FakeRelayClient second = await host.ConnectAsync(); + await second.SendAsync(RelayFrames.Setup()); + await second.SendAsync(RelayFrames.Prompt("hi", last: true)); + + Assert.Equal(WebSocketCloseStatus.InternalServerError, status); + Assert.Equal("hello", string.Concat(await second.ReadTextFramesUntilLastAsync())); + } + + // The caller hung up while the start opened the store: the cancelled call is abandoned and frees the id. + [Fact(Timeout = 30_000)] + public async Task AHangUpWhileTheStartOpensTheStoreFreesTheConversationForTheNextCall() + { + GatedCreateStore store = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(StoreYaml, reply, options => options.UseConversationStores(store)); + await using FakeRelayClient first = await host.ConnectAsync(); + await first.SendAsync(RelayFrames.Setup()); + await store.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + first.Abort(); + await host.WaitForConversationEndAsync("conversation-one"); + await using FakeRelayClient second = await host.ConnectAsync(); + await second.SendAsync(RelayFrames.Setup()); + await second.SendAsync(RelayFrames.Prompt("hi", last: true)); + + Assert.Equal("hello", string.Concat(await second.ReadTextFramesUntilLastAsync())); + } + + // Parity with the OpenAI Live adapter: a relay call the host's shutdown ends carries the cause "shutdown". + [Fact(Timeout = 30_000)] + public async Task AHostThatStopsUnderALiveCallEndsItWithTheShutdownCause() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + configure: options => _ = options.UseHooks(hook)); + harness.Socket.Queue(RelayFrames.Setup()); + _ = await hook.WaitForAsync(); + + harness.StopApplication(); + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal((ConversationEndReason.Faulted, (string?)"shutdown"), (ended.Reason, ended.Call!.Cause)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs index 5db21c00..9fbbaf46 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Audit.Memory; using AgentCore.Application.Conversation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.Fakes; @@ -12,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay { @@ -103,7 +103,7 @@ public async Task ACloseThatFindsTheSocketAbortedUnderIt_LogsNoFault() Assert.False(fault.Observed.IsCompleted, "a close on a socket already aborted was logged as a fault."); } - // P7x: the relay stops reading, so a reply fills the outbound queue, and then the relay drops. Teardown must not + // The relay stops reading, so a reply fills the outbound queue, and then the relay drops. Teardown must not // wait on a speech blocked on that queue: the socket is gone and nothing queued can be sent. The voice clock is // fake, so the interruption backstop never frees it; only the real close timeout would. [Fact(Timeout = 30_000)] @@ -135,7 +135,7 @@ public async Task ADropWhileAReplyIsBlockedOnAFullQueue_TearsDownWithoutWaitingO Assert.Equal("caller.hangup", await EndReasonAsync(harness, "conversation-stalled-drop")); } - // Section 7.1: teardown never throws out of the request handler, so a close that fails still ends the conversation. + // Teardown never throws out of the request handler, so a close that fails still ends the conversation. [Fact(Timeout = 30_000)] public async Task ACloseThatThrows_StillEndsTheConversationAndReleasesItsSession() { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs index 163b9490..4051afbd 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs @@ -6,7 +6,6 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Conversation.Memory; using AgentCore.AspNetCore.DependencyInjection.Startup; @@ -17,43 +16,16 @@ using Microsoft.Extensions.Logging; using Xunit; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay { /// /// How the chain of one conversation closes when the socket ends. /// - /// - /// - /// Section 11, item 6 and T55/T56: every conversation writes hash-chained events ending in - /// conversation.ended, and the reason is one member of the closed set - /// names. The turn loop closes its own chain when the stage machine - /// reaches a terminal stage, and that is the only ending the core can see. Every other ending is - /// the adapter's to write, because only the adapter sees the socket end — which is what - /// says in its own remarks. - /// - /// - /// Every test here drives TelnyxRelayConnection.RunAsync over - /// rather than over a real port. The close of a conversation is exactly the moment a real socket stops - /// being observable — aborts its own socket on the way out, so a - /// graceful vendor close cannot be scripted over the wire at all — and the fake is what lets a test - /// script the vendor's own close frame, a faulting write loop, and a host that stops, each on its - /// own. Everything else about the connection is the real thing, including the session factory, the - /// store, the observers, and the audit queue AddAgentCore registers. - /// - /// - /// Every test here runs offline against a fake model. There is no Telnyx account, no network conversation, - /// and no API key anywhere in this file. That is T59. - /// - /// public sealed class TelnyxRelayConversationEndTests { /// A document whose first turn moves the machine into a terminal stage. - /// - /// The transition carries no guard, so one turn is enough to reach close and the turn - /// loop closes the chain itself with agent.completed. That is the one ending teardown - /// must not write over. - /// private const string TerminalStageYaml = """ apiVersion: agentcore/v1 @@ -134,8 +106,8 @@ public async Task AConnectionWhoseWriteLoopFaulted_WritesOneConversationEndedTha [Fact(Timeout = 30_000)] public async Task AHostThatStopsUnderALiveConversation_WritesOneConversationEndedThatNamesTheFault() { - // The caller did not hang up: the process went away underneath them. The closed set of - // section 4 holds four endings and this is not one of the other three, so the honest one is + // The caller did not hang up: the process went away underneath them. The closed set of endings + // holds four and this is not one of the other three, so the honest one is // the fault. Recording it as caller.hangup would have a report count a shutdown as a caller // choosing to leave. using FragmentingChatClient reply = new("hello"); @@ -208,10 +180,10 @@ public async Task ASocketThatEndedBeforeTheSetupFrame_WritesNothingAndTearsDownC [Fact(Timeout = 30_000)] public async Task AClockThatThrowsWhileTheChainCloses_StillTearsDownAndStillReleasesTheSession() { - // Section 7.1: teardown never throws out of the request handler. The one input the closing + // Teardown never throws out of the request handler. The one input the closing // event reads that can throw is the clock, so it is the one a test can make throw. A throw // here must cost the chain its last event and nothing else — never the session close behind - // it, which is what waits for the words the conversation's last turn still owed store 1. + // it, which is what waits for the words the conversation's last turn still owed the transcript store. FaultingClock clock = new(); using FragmentingChatClient reply = new("hello"); await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( @@ -238,10 +210,10 @@ public async Task EndConversation_HangUp_FlushesTranscriptBeforeSessionRemoval() { // Arrange // - // Store 1 is written off the turn, so a conversation can end with its last words still in flight. + // The transcript store is written off the turn, so a conversation can end with its last words still in flight. // The session is the only thing that can wait for them, so once it leaves the store nothing // can: the record of that call would lose the turn the caller just had, and no error would - // say so. Same shape as the teardown that removed a session without closing its chain. + // say so. ParkingConversationStore transcript = new(); using FragmentingChatClient reply = new("your order ships Friday"); ConversationSessionFactory factory = TranscriptBackedSessions(TelnyxRelayTurnTests.PolicyYaml, reply, transcript); @@ -269,7 +241,7 @@ public async Task EndConversation_HangUp_FlushesTranscriptBeforeSessionRemoval() // Assert Assert.True( sessions.TranscriptLandedAtClose, - "the close returned while store 1 still owed the conversation its last turn."); + "the close returned while the message store still owed the conversation its last turn."); Assert.Null(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-flush", TestContext.Current.CancellationToken)); } @@ -308,7 +280,7 @@ public async Task StartConversation_SecondSetupFrame_FlushesTheReplacedTranscrip await WaitForSessionAsync(harness, "conversation-second"); Assert.True( sessions.TranscriptLandedAtClose, - "the close returned while store 1 still owed its call the last turn."); + "the close returned while the message store still owed its call the last turn."); } [Fact(Timeout = 30_000)] @@ -318,7 +290,7 @@ public async Task EndConversation_StoreThatThrowsOnTheClose_LogsItAndStillTearsD // // IConversationSessions is a public seam, and the close is the last thing teardown does. The // in-memory store never fails, so a store held over the network is the only one that can - // fail here — and its failure must not leave the request handler, which section 7.1 forbids. + // fail here — and its failure must not leave the request handler. EventObservedLoggerProvider capture = new("ConversationCloseFaulted"); using FragmentingChatClient reply = new("your order ships Friday"); FaultingConversationSessions sessions = new( @@ -389,7 +361,7 @@ public async Task EndConversation_StoreThatNeverAnswersTheClose_TimesOutRatherTh } } - /// The same factory over the memory store, for a test with no store 1 of its own. + /// The same factory over the memory store, for a test with no transcript store of its own. /// The document to compile. /// The model behind every reference in it. /// The factory. @@ -398,17 +370,11 @@ private static ConversationSessionFactory SessionsFor(string yaml, IChatClient r return TranscriptBackedSessions(yaml, reply, new InMemoryConversationStore()); } - /// Builds the session factory of one document over a given store 1. + /// Builds the session factory of one document over a given transcript store. /// The document, as YAML. /// The model behind every agent. /// Where the words of a conversation are written. /// The factory, ready to register over the one AddAgentCore built. - /// - /// A document that names no providers.conversations compiles onto the memory store, so this is the - /// only seam a test has for putting a slow store behind a conversation. It is why the whole factory is - /// rebuilt rather than decorated: the store is compiled into the agent, and the factory holds - /// the compiled agent. - /// private static ConversationSessionFactory TranscriptBackedSessions( string yaml, IChatClient reply, IConversationStore transcript) { @@ -443,11 +409,6 @@ private static async Task WaitQuietlyAsync(Task task, TimeSpan bound) /// Waits for one connection to finish its own teardown. /// The running connection. /// A task that completes once teardown has run to its end. - /// - /// The connection task is awaited through a guard rather than directly, so a teardown that - /// throws — which section 7.1 forbids — fails here by name instead of surfacing as whatever - /// assertion happened to run next. - /// private static async Task WaitForTeardownAsync(RelayConnectionHarness harness) { using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(20)); @@ -512,11 +473,6 @@ private static async Task WaitForCompletedConversationAsync /// The connection that has already torn down. /// The id of the conversation. /// The events of that conversation, oldest first. - /// - /// The queue is what keeps the append off the turn, so a reader that wants the rows now asks - /// for them now. Nothing here waits on the chain being non-empty: every test that calls this - /// has already waited for the teardown that writes the last event. - /// private static async Task> ReadChainAsync( RelayConnectionHarness harness, string conversationId) @@ -556,13 +512,6 @@ private static IConversationSessions Sessions(RelayConnectionHarness harness) /// /// A clock a test breaks on demand. /// - /// - /// is the one call ConversationSession.EndConversation makes that can - /// throw at all: the reason is a member of a closed set the connection picks itself, and the - /// dispatcher behind the event swallows everything an observer raises. Breaking the clock is - /// therefore the only way a test can reach the guard that keeps section 7.1's promise — teardown - /// never throws out of the request handler. - /// internal sealed class FaultingClock : TimeProvider { private volatile bool _failing; @@ -574,10 +523,6 @@ public void FailFromNowOn() } /// - /// - /// Only this reading fails. The timestamps a turn measures with and the timers the pump's idle - /// deadline runs on are left alone, so a broken clock ends nothing but the one event under test. - /// public override DateTimeOffset GetUtcNow() { return _failing @@ -587,7 +532,7 @@ public override DateTimeOffset GetUtcNow() } /// - /// The live sessions, with a note of what store 1 had done by the time a close returned. + /// The live sessions, with a note of what the transcript store had done by the time a close returned. /// internal sealed class OrderedConversationSessions(IConversationSessionFactory factory, Func transcriptLanded) : IConversationSessions, IDisposable @@ -607,7 +552,7 @@ public void Dispose() /// Gets a task that completes when a session has finished closing. public Task Closed => _closed.Task; - /// Gets whether store 1 had written the conversation's words by the time the close returned. + /// Gets whether the transcript store had written the conversation's words by the time the close returned. public bool? TranscriptLandedAtClose { get; private set; } /// @@ -634,11 +579,6 @@ public async ValueTask CloseAsync(string entry, string conversationId, Cancellat /// Sessions that open normally and fail only when asked to close one. /// Builds the sessions this holds. /// What the close throws. - /// - /// It throws before its first await rather than from inside an async body, which is the harder of - /// the two for teardown to catch: a synchronous throw out of a method lands - /// at the conversation site, not on the returned task. - /// internal sealed class FaultingConversationSessions(IConversationSessionFactory factory, Exception fault) : IConversationSessions, IDisposable { private readonly InMemoryConversationSessions _inner = new( @@ -702,11 +642,6 @@ public ValueTask GetOrOpenAsync(string entry, string? conve } /// - /// - /// It ignores on purpose. Teardown passes - /// there, so a store that honoured a token would prove - /// nothing about the bound teardown puts on the wait itself. - /// public async ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { await _release.Task; diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs index e5a7846e..f4c92cef 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; using System.Text; using System.Text.Json.Nodes; using AgentCore.Application.Transcript; @@ -82,7 +80,7 @@ public async Task AFinalPromptMidStep_ThenTheRelaysReport_KeepsTheHeardText() Assert.Equal(["Hello"], model.Requests.Last().Where(message => message.Role == ChatRole.Assistant).Select(message => message.Text)); } - private static async Task ReadTokensUntilAsync(FakeRelayClient relay, string expected) + internal static async Task ReadTokensUntilAsync(FakeRelayClient relay, string expected) { StringBuilder text = new(); using CancellationTokenSource deadline = new(Bound); @@ -95,95 +93,5 @@ private static async Task ReadTokensUntilAsync(FakeRelayClient relay, string exp } } } - - /// - /// Step one says "Hello there, ", holds until or until its request is cancelled, then - /// says more and calls the tool. A request after a tool result, or for any later words, answers at once. - /// - private sealed class GatedStepChatClient : IChatClient - { - private readonly TaskCompletionSource _resume = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource Held { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public ConcurrentQueue> Requests { get; } = new(); - - public void Resume() - { - _ = _resume.TrySetResult(); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - List sent = [.. messages.Where(message => message.Role != ChatRole.System)]; - Requests.Enqueue(sent); - string id = Guid.NewGuid().ToString("N"); - ChatMessage last = sent[^1]; - - if (last.Role == ChatRole.Tool) - { - yield return Text("Done.", id); - yield break; - } - - if (last.Text != "hi") - { - yield return Text("Sure.", id); - yield break; - } - - yield return Text("Hello there, ", id); - _ = Held.TrySetResult(); - try - { - await _resume.Task.WaitAsync(cancellationToken); - } - catch (OperationCanceledException) - { - _ = Stopped.TrySetResult(); - throw; - } - - yield return Text("and more.", id); - yield return new ChatResponseUpdate( - ChatRole.Assistant, - [new FunctionCallContent("call_1", options!.Tools!.OfType().First().Name, new Dictionary())]) - { - ResponseId = id, - MessageId = id, - }; - } - - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - List updates = []; - await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) - { - updates.Add(update); - } - - return updates.ToChatResponse(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - } - - private static ChatResponseUpdate Text(string text, string id) - { - return new ChatResponseUpdate(ChatRole.Assistant, text) { ResponseId = id, MessageId = id }; - } - } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameReaderTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameReaderTests.cs index c1ecbfd4..63dbe33c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameReaderTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameReaderTests.cs @@ -1,6 +1,6 @@ using System.Text; using System.Text.Json; -using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; using Xunit; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay @@ -8,16 +8,8 @@ namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay /// /// The reader against the frames the vendor documents. /// - /// - /// Every JSON body below is copied from the Telnyx Conversation Relay page, so these are golden - /// inputs and not invented ones. Every test here runs offline. There is no network conversation and no API - /// key anywhere in this file. - /// public sealed class TelnyxRelayFrameReaderTests { - // ------------------------------------------------------------------------------------------- - // The five inbound frames. - // ------------------------------------------------------------------------------------------- [Fact] public void ASetupFrame_ReadsTheIdsTheTransferWillNeed() @@ -62,7 +54,7 @@ public void APromptFrame_ReadsTheTranscriptAndTheFinalFlag() [Fact] public void AnInterruptFrame_ReadsTheHeardTextAndTheDurationInMilliseconds() { - // Section 7.1 first printed a TimeSpan here. The wire carries an integer of milliseconds. + // The wire carries an integer of milliseconds. const string Json = /*lang=json,strict*/ """ @@ -95,14 +87,12 @@ public void AnErrorFrame_ReadsTheDescription() Assert.StartsWith("Invalid message", Assert.IsType(frame).Description); } - // ------------------------------------------------------------------------------------------- // What the reader refuses, and how. It never throws on vendor input. - // ------------------------------------------------------------------------------------------- [Fact] public void AnUnknownType_IsRefusedAndNamed() { - // A vendor that adds a frame must not be able to drop a conversation. Section 7.1. + // A vendor that adds a frame must not be able to drop a conversation. Assert.False(Read(/*lang=json,strict*/ """{"type":"whisper","text":"x"}""", out RelayFrame? frame, out string? unknown)); Assert.Null(frame); Assert.Equal("whisper", unknown); @@ -122,7 +112,7 @@ public void ADecimalInterruptDuration_IsARefusedBodyAndNotAnUnreadableFrame() { // The wire carries an integer of milliseconds, and the record binds one. A vendor that // starts sending 1820.5 must not thereby end a conversation at the exact moment of a barge-in, so - // the type is named on refusedType and the caller keeps the socket. Section 7.1. + // the type is named on refusedType and the caller keeps the socket. const string Json = /*lang=json,strict*/ """ @@ -217,9 +207,7 @@ public void TrailingBytesAfterACompleteObject_AreToleratedNotRejected() Assert.Equal("1", Assert.IsType(frame).Digit); } - // ------------------------------------------------------------------------------------------- // The outbound wire, so a future change to Type cannot silently drop the discriminator. - // ------------------------------------------------------------------------------------------- [Fact] public void ARelayToken_SerializesWithTheTextDiscriminator() diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs index 44dbf934..b8525023 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs @@ -1,5 +1,6 @@ -using AgentCore.AspNetCore.Vendors.TelnyxRelay; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; +using AgentCore.AspNetCore.Voice.Ports; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs new file mode 100644 index 00000000..02e88a27 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs @@ -0,0 +1,194 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// On the relay: what the caller said, and what the caller heard. + public sealed class TelnyxRelayLineSpokenTests + { + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); + + // The first turn moves the machine into a terminal stage, so the engine ends the conversation in the turn + // whose reply the caller is still hearing. + private const string EndsAfterTheFirstTurnYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + - { id: closer, instructions: "close the conversation" } + entries: + main: + policy: + initial: greeting + stages: + - { id: greeting, agent: greeter, to: [ { stage: close } ] } + - { id: close, agent: closer, terminal: true } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + """; + + // The relay reports no end of playback, so a reply nobody talked over is heard in full once the call ends. + [Fact(Timeout = 30_000)] + public async Task AFinalPromptAndAFullyPlayedReplyAreTwoLines() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello there caller"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply, options => _ = options.UseHooks(hook)); + FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReplySentAsync(hook); + await relay.DisposeAsync(); + _ = await hook.WaitForAsync(line => line.Speaker == Speaker.Agent); + + Assert.Equal([(Speaker.Caller, "hi"), (Speaker.Agent, "hello there caller")], Lines(hook)); + } + + // The caller talked over the reply after all its text went to the relay, which still plays it. + [Fact(Timeout = 30_000)] + public async Task ABargeInAfterTheWholeReplyWasSentCutsItsLine() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello there caller"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply, options => _ = options.UseHooks(hook)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReplySentAsync(hook); + await relay.SendAsync(RelayFrames.Interrupt("hello there", durationMs: 400)); + _ = await hook.WaitForAsync(line => line.Speaker == Speaker.Agent); + + Assert.Equal([(Speaker.Caller, "hi"), (Speaker.Agent, "hello there")], Lines(hook)); + } + + // The caller's next words leave the window of the reply before them open for the report's wait, + // and with no report its line is the whole reply; it needs no call end. + [Fact(Timeout = 30_000)] + public async Task TheCallersNextWordsWithNoReportReleaseTheWholeLineAfterTheWait() + { + RecordingHook hook = new(); + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + using FragmentingChatClient reply = new("hello there caller", "you are welcome"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, options => { _ = options.UseHooks(hook); options.TimeProvider = clock; }); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReplySentAsync(hook); + await relay.SendAsync(RelayFrames.Prompt("thanks", last: true)); + await clock.WaitForTimersAsync(clock.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1).WaitAsync(Bound, TestContext.Current.CancellationToken); + clock.Advance(VoiceOptions.DefaultHeardTextWait); + _ = await hook.WaitForAsync(line => line.Text == "thanks"); + + Assert.Equal([(Speaker.Caller, "hi"), (Speaker.Agent, "hello there caller"), (Speaker.Caller, "thanks")], Lines(hook)); + } + + // The relay sends the final prompt first and its report of what was heard after it + // (TelnyxRelayFinalPromptCutTests); a report inside the wait still cuts the line, which keeps its place. + [Fact(Timeout = 30_000)] + public async Task AReportThatFollowsTheCallersNextWordsStillCutsTheLine() + { + RecordingHook hook = new(); + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + using HeldPromptChatClient reply = new("hello there caller", "you are welcome"); + reply.ReleaseFirstTurn(); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, options => { _ = options.UseHooks(hook); options.TimeProvider = clock; }); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReplySentAsync(hook); + await relay.SendAsync(RelayFrames.Prompt("thanks", last: true)); + + // The next reply holds before its first word, so the report can only be about the reply before it. + await reply.SecondTurnStarted.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + await relay.SendAsync(RelayFrames.Interrupt("hello there", durationMs: 400)); + _ = await hook.WaitForAsync(line => line.Text == "thanks"); + + Assert.Equal([(Speaker.Caller, "hi"), (Speaker.Agent, "hello there"), (Speaker.Caller, "thanks")], Lines(hook)); + reply.ReleaseSecondTurn(); + } + + // The cut scenario of TelnyxRelayFinalPromptCutTests.AFinalPromptMidStep_ThenTheRelaysReport_KeepsTheHeardText: + // its stored rows ("Hello", then "Sure.") are what the caller heard, in the order it was said. + [Fact(Timeout = 30_000)] + public async Task ACutReplyIsTheLineTheCallerHeard() + { + RecordingHook hook = new(); + GatedStepChatClient model = new(); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TwoStepChatClient.Yaml, model, options => { TwoStepChatClient.BindTool(options); _ = options.UseHooks(hook); }); + FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "line-cut")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await TelnyxRelayFinalPromptCutTests.ReadTokensUntilAsync(relay, "Hello there, "); + await model.Held.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + await relay.SendAsync(RelayFrames.Prompt("wait", last: true)); + await model.Stopped.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + await relay.SendAsync(RelayFrames.Interrupt("Hello", durationMs: 400)); + await ReplySentAsync(hook); + await relay.DisposeAsync(); + _ = await hook.WaitForAsync(line => line.Text == "Sure."); + + Assert.Equal( + [(Speaker.Caller, "hi"), (Speaker.Agent, "Hello"), (Speaker.Caller, "wait"), (Speaker.Agent, "Sure.")], + Lines(hook)); + } + + // A spoken line is let through after ConversationEnded: it is a record of the call, not a turn + // notice. So the reply of the turn that ended the conversation still carries its turn, and the caller's words + // after the end, before the call closes, are still a line. + [Fact(Timeout = 30_000)] + public async Task TheReplyOfTheTurnThatEndedTheConversationAndTheCallersWordsAfterItAreStillLines() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("goodbye then"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(EndsAfterTheFirstTurnYaml, reply, options => _ = options.UseHooks(hook)); + FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReplySentAsync(hook); + ConversationEnded ended = await hook.WaitForAsync(); + await relay.SendAsync(RelayFrames.Prompt("okay bye", last: true)); + await relay.DisposeAsync(); + _ = await hook.WaitForAsync().WaitAsync(Bound, TestContext.Current.CancellationToken); + + TurnStarted turn = Assert.Single(hook.Of()); + LineSpoken line = hook.Of().First(line => line.Speaker == Speaker.Agent); + Assert.Equal([(Speaker.Caller, "hi"), (Speaker.Agent, "goodbye then"), (Speaker.Caller, "okay bye")], Lines(hook)); + Assert.Equal(turn.Scope.TurnIndex, line.Scope.TurnIndex); + Assert.Equal(ConversationEndReason.AgentCompleted, ended.Reason); + } + + // The reply's end latency is read as its last step ends, after every word went to the relay. + private static async Task ReplySentAsync(RecordingHook hook) + { + _ = await hook.WaitForAsync(latency => latency.Metric == LatencyMetric.TimeToReplyEnd) + .WaitAsync(Bound, TestContext.Current.CancellationToken); + } + + private static IEnumerable<(Speaker Speaker, string Text)> Lines(RecordingHook hook) + { + return hook.Of().Select(line => (line.Speaker, line.Text)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs index 103363fe..dec61e0d 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs @@ -1,7 +1,11 @@ using System.Buffers; using System.Text.Json; -using AgentCore.AspNetCore.Vendors.TelnyxRelay; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Wire; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Turns; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs index 2b324e2d..371d95e5 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs @@ -1,6 +1,6 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Vendors.TelnyxRelay; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Vendors.TelnyxRelay.Connection; +using AgentCore.AspNetCore.Voice.Transport; using Xunit; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTakeoverTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTakeoverTests.cs new file mode 100644 index 00000000..1a3fc5be --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTakeoverTests.cs @@ -0,0 +1,66 @@ +using System.Net.WebSockets; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// LiveKit's duplicate-identity rule on the relay: the newest + /// connection for a call id holds the call, the older one is dropped with a reason, and the conversation goes on. + /// + public sealed class TelnyxRelayTakeoverTests + { + private const string ReplacedDescription = "replaced by a newer connection for the same call"; + + [Fact(Timeout = 30_000)] + public async Task ASecondSocketForTheSameCallTakesItOverAndTheFirstIsClosedAsReplaced() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(hook)); + await using FakeRelayClient first = await host.ConnectAsync(); + await first.SendAsync(RelayFrames.Setup()); + _ = await hook.WaitForAsync(); + ConversationSession before = (await host.FindSessionAsync("conversation-one"))!; + + await using FakeRelayClient second = await host.ConnectAsync(); + await second.SendAsync(RelayFrames.Setup()); + (WebSocketCloseStatus? Status, string? Description) closed = await first.ReadCloseAsync(); + await second.SendAsync(RelayFrames.Prompt("hi", last: true)); + string spoken = string.Concat(await second.ReadTextFramesUntilLastAsync()); + await before.FlushNoticesAsync(); + + Assert.Equal(((WebSocketCloseStatus?)WebSocketCloseStatus.NormalClosure, (string?)ReplacedDescription), closed); + Assert.Equal("hello", spoken); + Assert.Same(before, await host.FindSessionAsync("conversation-one")); + Assert.Empty(hook.Of()); + } + + // The newest setup frame for the same call id takes the call over on the same socket, which stays up. + [Fact(Timeout = 30_000)] + public async Task ASecondSetupFrameForTheSameCallKeepsItsConversationGoing() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply, options => options.UseHooks(hook)); + await using FakeRelayClient relay = await host.ConnectAsync(); + await relay.SendAsync(RelayFrames.Setup()); + _ = await hook.WaitForAsync(); + ConversationSession before = (await host.FindSessionAsync("conversation-one"))!; + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + string spoken = string.Concat(await relay.ReadTextFramesUntilLastAsync()); + await before.FlushNoticesAsync(); + + Assert.Equal("hello", spoken); + Assert.Same(before, await host.FindSessionAsync("conversation-one")); + Assert.Empty(hook.Of()); + _ = Assert.Single(hook.Of()); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTurnTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTurnTests.cs index 2a3fb3a5..78c33cf5 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTurnTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayTurnTests.cs @@ -1,17 +1,13 @@ using AgentCore.AspNetCore.Tests.Fakes; using Microsoft.Extensions.Logging; using Xunit; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay { /// /// One relay socket runs one conversation, and one final prompt runs one turn. /// - /// - /// Every test here runs offline against a fake model and a fake relay. There is no Telnyx account, - /// no network conversation, and no API key anywhere in this file. - /// public sealed class TelnyxRelayTurnTests { internal const string PolicyYaml = @@ -34,9 +30,6 @@ public sealed class TelnyxRelayTurnTests agent: greeter """; - // ------------------------------------------------------------------------------------------- - // The happy path. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AFinalPrompt_RunsOneTurnAndSendsOneTextFrameForEachUpdate() @@ -57,8 +50,7 @@ public async Task AFinalPrompt_RunsOneTurnAndSendsOneTextFrameForEachUpdate() [Fact] public async Task ASetupFrame_NamesTheConversationAfterTheConversationSessionId() { - // conversationSessionId groups the legs of one logical conversation, so it survives the warm transfer of - // slice 2. A leg id would not. + // conversationSessionId groups the legs of one logical conversation, so it survives a warm transfer. A leg id would not. using FragmentingChatClient reply = new("hello"); await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(PolicyYaml, reply); await using FakeRelayClient relay = await host.ConnectAsync(); @@ -72,9 +64,7 @@ public async Task ASetupFrame_NamesTheConversationAfterTheConversationSessionId( Assert.Equal("logical-conversation-7", session.ConversationId); } - // ------------------------------------------------------------------------------------------- - // The rule this task exists for: the read loop never waits for the turn. - // ------------------------------------------------------------------------------------------- + // The read loop never waits for the turn. [Fact(Timeout = 30_000)] public async Task AFurtherFrame_ReachesTheConnectionWhileTheReplyIsStillStreaming() @@ -84,7 +74,7 @@ public async Task AFurtherFrame_ReachesTheConnectionWhileTheReplyIsStillStreamin // below is sent, and observed to have landed, before the gate on the reply is ever // released — so a read loop that blocked on the turn would leave the "await capture" line // hanging rather than let this test finish. Dtmf carries no barge-in logic of its own, so - // this proves only the rule this task owns, and nothing Task 6 will add. + // this proves only that rule. // // No test in this project carries a timeout, so TestContext.Current.CancellationToken never // fires on its own for one test. The ten-second deadline below is this test's own backstop, @@ -141,9 +131,6 @@ public async Task AFurtherFrame_ReachesTheConnectionWhileTheReplyIsStillStreamin Assert.Equal("hello there caller", string.Concat(tokens)); } - // ------------------------------------------------------------------------------------------- - // What the read loop must survive. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AnInterimPrompt_RunsNoTurn() @@ -202,7 +189,7 @@ public async Task AnUnknownFrameType_IsIgnoredAndTheConversationGoesOn() [Fact(Timeout = 30_000)] public async Task ADecimalDurationOnAnInterruptFrame_IsRefusedAndTheConversationGoesOn() { - // Section 7.1: a vendor that changes a frame must not be able to drop a conversation. The type is + // A vendor that changes a frame must not be able to drop a conversation. The type is // known and only one field will not bind, so the frame is refused and the socket lives. // A decimal here would otherwise end a live conversation at the exact moment of a barge-in. using FragmentingChatClient reply = new("hello"); @@ -313,10 +300,10 @@ public async Task AnErrorFrame_KeepsTheSocketOpen() [Fact(Timeout = 30_000)] public async Task ASecondSetupFrame_ReplacesTheFirstSessionAndReleasesIt() { - // One socket carries one conversation, so a second setup frame is the vendor's defect. Section 7.1 - // still forbids dropping a conversation over one, so it replaces rather than refuses. Teardown only + // One socket carries one conversation, so a second setup frame is the vendor's defect. A conversation is never + // dropped over one, so it replaces rather than refuses. Teardown only // ever closes the session the connection currently holds, so a first session left behind - // would never have anything wait for the words it still owed store 1. + // would never have anything wait for the words it still owed the transcript store. using FragmentingChatClient reply = new("hello"); EventObservedLoggerProvider capture = new("SecondSetupFrame"); await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( @@ -359,9 +346,6 @@ public async Task ASecondSetupFrame_ReplacesTheFirstSessionAndReleasesIt() } } - // ------------------------------------------------------------------------------------------- - // What teardown does. - // ------------------------------------------------------------------------------------------- [Fact(Timeout = 30_000)] public async Task ARelayThatVanishesWithNoCloseFrame_ReleasesTheSession() @@ -389,12 +373,6 @@ public async Task ARelayThatVanishesWithNoCloseFrame_ReleasesTheSession() /// /// Completes once the connection writes the "the caller pressed a key" line. /// - /// - /// Matched by rather than its numeric id, because the numeric id is not - /// a value a test should depend on. This is the only signal a dtmf frame leaves behind — the digit - /// itself never reaches a log line — so it is also the only way a test can prove the connection - /// read the frame at all. - /// internal sealed class DtmfObservedLoggerProvider : ILoggerProvider { private readonly TaskCompletionSource _observed = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs index a1d1a781..afbc9e74 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs @@ -1,4 +1,4 @@ -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Threading; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs index f503413e..c60532d4 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs @@ -1,5 +1,6 @@ -using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Routing; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs index 6a3643f3..28864157 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs @@ -1,4 +1,5 @@ -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Routing; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs index 4b65464a..3077ecd9 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs @@ -1,5 +1,5 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs index 06fb95a9..e87d5870 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs @@ -1,8 +1,9 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Routing; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -55,7 +56,7 @@ public void ADialOutVendorRoutesNothingAndSaysWhy() { ConversationSeamAdapters seams = Build(conversationKind: "dial-out-fake", new FakeDialOut("dial-out-fake")); - // Section 12 asks this case to route nothing AND say so. A route that vanishes in silence is + // This case must route nothing AND say so. A route that vanishes in silence is // how a deployment loses every call to a 404 with nothing to read. Assert.Null(seams.Handler); Assert.NotNull(seams.Unroutable); @@ -101,9 +102,6 @@ public void TheDefaultPatternIsVendorNeutral() Assert.Equal("/v1/{entry}/call", ConversationEndpointRouteBuilderExtensions.DefaultPattern); } - // --------------------------------------------------------------------------------------------- - // Helpers. - // --------------------------------------------------------------------------------------------- /// Runs the conversation seam over one document and the adapters a host registered. /// The value providers.conversation.kind carries. @@ -131,7 +129,7 @@ private static string Address(WebApplication app) .First(); } - /// Writes one document that names both blocks section 8.2 requires of one another. + /// Writes one document that names both blocks that require each other. /// The value providers.conversation.kind carries. /// The document text. private static string Document(string conversationKind) diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs index 9aa80f24..ae306310 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs @@ -1,6 +1,7 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Turns; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs deleted file mode 100644 index fe14b612..00000000 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Xunit; - -namespace AgentCore.AspNetCore.Tests.Voice -{ - /// - /// The collection the latency tests run in, alone. - /// - [CollectionDefinition(Name, DisableParallelization = true)] - public sealed class ConversationTurnLatencySuite - { - /// The name both the definition and the test class name it by. - public const string Name = "conversation turn latency"; - } -} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs index 447688a7..3834e720 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs @@ -12,7 +12,9 @@ // pass (FakeTimeProvider.WaitForTimersAsync), then advances the clock. using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; using AgentCore.TestSupport; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs index e2fce64b..58d4ac0a 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs @@ -1,6 +1,10 @@ -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -69,7 +73,7 @@ public async Task AFinalTranscript_InterruptsAStepTheCallersSpeechHeldBeforeItCa await _session.WaitForIdleAsync(Ct); } - // generation.py:748-768 and the owner's ruling that a cut keeps exactly the heard text: a final prompt cuts a + // generation.py:748-768, and a cut keeps exactly the heard text: a final prompt cuts a // step mid-way, which stops the turn at once with the text forwarded; the reply then waits for the // transport's report of what was heard, recuts its turn to it, and only then does the next reply's turn start. [Fact(Timeout = 30_000)] @@ -96,6 +100,42 @@ public async Task AFinalTranscriptMidStep_CutsTheTurnAtOnce_ThenRecutsItToTheHea await _session.WaitForIdleAsync(Ct); } + // The reply was all sent and its speech done when the final prompt came; the report that follows + // the prompt inside the wait cuts the line and the turn alike. + [Fact(Timeout = 30_000)] + public async Task AReportInsideTheWaitAFinalPromptLeftOpen_CutsTheLineAndTheTurn() + { + (VoiceActivity activity, UserTurnHandler handler, Func line, _) = WithLines(); + await SpokenWholeAsync(activity, "hello there caller"); + + handler.OnFinalTranscript("thanks"); + await _time.WaitForTimersAsync(_time.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1); + await activity.InterruptByAudioActivity("hello there", TimeSpan.FromMilliseconds(400)); + + Assert.Equal("hello there", await line().HeardWhenSettledAsync()); + Assert.Equal([(0, new TurnCut("hello there", TimeSpan.FromMilliseconds(400)))], _port.Cuts); + _port.Turn(2).End(); + } + + // A report that lands once the wait is over changes neither the line nor the turn, and is logged. + [Fact(Timeout = 30_000)] + public async Task AReportAfterTheWaitAFinalPromptLeftOpen_ChangesNeitherTheLineNorTheTurn() + { + (VoiceActivity activity, UserTurnHandler handler, Func line, RecordingLoggerFactory logs) = WithLines(); + await SpokenWholeAsync(activity, "hello there caller"); + + handler.OnFinalTranscript("thanks"); + await _time.WaitForTimersAsync(_time.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1); + _time.Advance(VoiceOptions.DefaultHeardTextWait); + Assert.Equal("hello there caller", await line().HeardWhenSettledAsync()); + await activity.InterruptByAudioActivity("hello there", TimeSpan.FromMilliseconds(400)); + + Assert.Equal("hello there caller", line().HeardText); + Assert.Empty(_port.Cuts); + _ = Assert.Single(logs.Of(37)); + _port.Turn(2).End(); + } + // agent_activity.py:2788-2796: scheduling paused by a close while the user turn waited for the speech it // interrupted, so the turn is skipped: no engine turn starts, and the user-turn task ends without a fault. [Fact(Timeout = 30_000)] @@ -123,5 +163,24 @@ public async Task AFinalTranscriptWhoseSpeechEndsAfterAClosePausedScheduling_Sta Assert.Empty(logs.Of(28)); Assert.False(_port.Turn(2).Started.IsCompleted); } + + // A session of its own, logged, whose activity hands out what the caller heard of its first reply. + private (VoiceActivity Activity, UserTurnHandler Handler, Func Line, RecordingLoggerFactory Logs) WithLines() + { + RecordingLoggerFactory logs = new(); + VoiceSession session = new(_output, _time, logs.CreateLogger("voice")); + ReplyHearing? line = null; + VoiceActivity activity = new(session, _port, CancellationToken.None, agentLine: hearing => line ??= hearing); + return (activity, new UserTurnHandler(session, activity), () => line!, logs); + } + + private async Task SpokenWholeAsync(VoiceActivity activity, string text) + { + SpeechHandle reply = activity.GenerateReply("hi"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync(text); + turn.End(); + _ = await reply; + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs index 956e07d3..539838c3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs @@ -1,6 +1,10 @@ -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Turn.Lifecycle; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; @@ -40,7 +44,7 @@ public ValueTask DisposeAsync() return _output.DisposeAsync(); } - // VoiceActivity (plan G8): engine turns run one at a time, each once the previous one has ended. + // VoiceActivity: engine turns run one at a time, each once the previous one has ended. [Fact(Timeout = 30_000)] public async Task ASecondReply_StartsItsEngineTurnOnlyOnceTheFirstHasEnded() { @@ -64,7 +68,7 @@ public async Task ASecondReply_StartsItsEngineTurnOnlyOnceTheFirstHasEnded() } // EngineReplyStream.Cut: a turn still waiting on the one before it takes the cut the moment it starts, so the - // caller's words are kept (design section 3). + // caller's words are kept. [Fact(Timeout = 30_000)] public async Task AReplyInterruptedBeforeItsTurnStarted_CutsThatTurnTheMomentItStarts() { @@ -114,7 +118,7 @@ public async Task AStepEndingOnAToolCall_LeavesTheAgentThinkingWhileTheToolRuns( _ = await handle; } - // PipelineReply (design section 3): an interruption with no barge-in behind it keeps the text forwarded. + // PipelineReply: an interruption with no barge-in behind it keeps the text forwarded. [Fact(Timeout = 30_000)] public async Task AUserTurnInterruptingAReplyWhileItsToolRuns_CutsTheTurnToTheForwardedText() { @@ -213,7 +217,7 @@ public async Task ARecutRefusedAfterALaterTurnStarted_IsLoggedAsARefusedRecut() "First question", Task.CompletedTask, SpeechHandle.Create(_time, NullLogger.Instance), - new TurnMetrics(_time, userTurnEndedAt: null), + new TurnMetrics(_time, userTurnEndedAt: null, static (_, _, _) => { }), logs.CreateLogger("voice"), CancellationToken.None); _ = await _port.Turn(1).Started; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs index bfcf69e0..95d2346c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs @@ -1,5 +1,9 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -29,7 +33,6 @@ public ValueTask DisposeAsync() return _output.DisposeAsync(); } - // Final review probe D (2026-09-23). [Fact(Timeout = 30_000)] public async Task AFastToolsFiller_StopsWhenItsOwnToolReturns_WhileASlowToolStillRuns() { diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs index b79d4b22..4448100d 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs @@ -5,9 +5,12 @@ // turn instead of fake VAD/STT/LLM/TTS; chat-context assertions become assertions on the engine calls. using System.Text.Json; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; @@ -164,7 +167,7 @@ public async Task ABargeInWhileAToolRuns_EndsTheTurnWithTheHeardText() public async Task ATurnThatEndsWithACallThatNeverAnswered_SpeaksTheTextAfterItOnceAsItsOwnStep() { // A tool that throws out of the run leaves its call with no result, and the fallback line follows it - // (owner ruling 2026-09-23: the caller hears the fallback). + // (the caller hears the fallback). SpeechHandle handle = _activity.GenerateReply("Where is my order?"); ScriptedVoiceTurn turn = _port.Turn(1); await turn.TextAsync("Let me check."); @@ -202,7 +205,7 @@ public async Task ABargeInBeforeTheTurnEnds_SpeaksNoTextHeldBehindAnUnansweredCa [Fact(Timeout = 30_000)] public async Task ABargeInBeforeTheSpeechSaidAnything_CutsNoTurnAndLeavesTheSpeechToSpeak() { - // Turn identity (design section 3): a speech that has put no words out is not what the caller hears, + // Turn identity: a speech that has put no words out is not what the caller hears, // and with no earlier reply heard there is no turn to cut. SpeechHandle handle = _activity.GenerateReply("And the other one?"); ScriptedVoiceTurn turn = _port.Turn(1); @@ -222,7 +225,7 @@ public async Task ABargeInBeforeTheSpeechSaidAnything_CutsNoTurnAndLeavesTheSpee [Fact(Timeout = 30_000)] public async Task AUserTurnBeforeTheSpeechSaidAnything_CutsItsTurnWithNothingHeard() { - // Design section 3 row "Cut before output" (W04, G1 = Cut(turn, "", null)): the turn stops and keeps the + // Cut before output, Cut(turn, "", null): the turn stops and keeps the // caller's message, and the next reply's turn follows it. SpeechHandle handle = _activity.GenerateReply("First question"); ScriptedVoiceTurn first = _port.Turn(1); @@ -247,7 +250,7 @@ public async Task AUserTurnBeforeTheSpeechSaidAnything_CutsItsTurnWithNothingHea [Fact(Timeout = 30_000)] public async Task AReplyEndingOnAPendingApproval_SpeaksTheNotice() { - // Plan B11: a voice conversation has no surface to answer an approval on. + // A voice conversation has no surface to answer an approval on. SpeechHandle handle = _activity.GenerateReply("Send the email"); using JsonDocument empty = JsonDocument.Parse("{}"); _port.LastTurn = new TurnResult(_port.ConversationId, 0, string.Empty, string.Empty, string.Empty, false, null) diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs index 3dde9507..41bacf1f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs @@ -3,7 +3,8 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#. -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs index 95970080..2cda562a 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs @@ -1,4 +1,4 @@ -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Speech; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs index 51adc248..a41e0cbe 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs @@ -2,7 +2,7 @@ // commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#; realtime session removed. -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Speech; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs index ea1e8a41..523c7e0e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs @@ -1,4 +1,4 @@ -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Speech; using AgentCore.TestSupport; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs index bb31bd83..4a8f83e7 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs @@ -1,4 +1,4 @@ -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Threading; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs index 6465411c..e386450c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs @@ -1,5 +1,6 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs index 23b3bcf6..fe9ff0d5 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs @@ -2,11 +2,13 @@ // :380, test_run_context_with_filler_cancels_on_exit :399), 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 the scope is opened directly with the tool id's options rather than through +// itself, so the scope is opened directly with the tool id's options rather than through // RunContext.with_filler. using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs index 9be1a497..12c9c9e7 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs @@ -1,5 +1,9 @@ +using AgentCore.Application.Hooks.Notices; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -7,28 +11,24 @@ namespace AgentCore.AspNetCore.Tests.Voice { /// - /// The four latency readings of a pipeline reply, taken at LiveKit's points (plan 2.5) off a clock the + /// The four latency readings of a pipeline reply, taken at LiveKit's points off a clock the /// test owns. /// - [Collection(ConversationTurnLatencySuite.Name)] public sealed class TurnMetricsTests : IAsyncDisposable { - private const string FirstToken = "agentcore.turn.time_to_first_token"; - private const string FirstSpeech = "agentcore.turn.time_to_first_speech"; - private const string ReplyEnd = "agentcore.turn.time_to_reply_end"; - private const string BargeIn = "agentcore.barge_in.latency"; - private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); private readonly FakeConversationOutput _output = new(); private readonly ScriptedVoicePort _port = new(); + private readonly LatencyRecorder _latency = new(); + private readonly VoiceActivity _activity; public TurnMetricsTests() { - _activity = new VoiceActivity(new VoiceSession(_output, _time, NullLogger.Instance), _port, CancellationToken.None); + _activity = new VoiceActivity(new VoiceSession(_output, _time, NullLogger.Instance), _port, CancellationToken.None, latency: _latency.Record); } public ValueTask DisposeAsync() @@ -36,12 +36,11 @@ public ValueTask DisposeAsync() return _output.DisposeAsync(); } - // Plan 2.5: a reply with no tool is one step, so each clock reports once, at LiveKit's points. + // A reply with no tool is one step, so each clock reports once, at LiveKit's points. [Fact(Timeout = 30_000)] public async Task AReplyThatEndsOnItsOwn_ReportsOneReadingOnEachOfTheThreeTurnClocks() { long userTurnEndedAt = _time.GetTimestamp(); - using LatencyReadings readings = new(); SpeechHandle handle = _activity.GenerateReply("one", userTurnEndedAt: userTurnEndedAt); ScriptedVoiceTurn turn = _port.Turn(1); @@ -53,9 +52,9 @@ public async Task AReplyThatEndsOnItsOwn_ReportsOneReadingOnEachOfTheThreeTurnCl turn.End(); _ = await handle; - Assert.Equal([0.200], readings.Of(FirstToken)); - Assert.Equal([0.200], readings.Of(FirstSpeech)); - Assert.Equal([0.200], readings.Of(ReplyEnd)); + Assert.Equal([0.200], _latency.Of(LatencyMetric.TimeToFirstToken)); + Assert.Equal([0.200], _latency.Of(LatencyMetric.TimeToFirstSpeech)); + Assert.Equal([0.200], _latency.Of(LatencyMetric.TimeToReplyEnd)); } [Fact(Timeout = 30_000)] @@ -63,7 +62,6 @@ public async Task ATwoStepReply_ReportsATimeToFirstTokenPerStepAndOneFirstSpeech { long userTurnEndedAt = _time.GetTimestamp(); _time.Advance(TimeSpan.FromMilliseconds(50)); - using LatencyReadings readings = new(); SpeechHandle handle = _activity.GenerateReply("What's the weather?", userTurnEndedAt: userTurnEndedAt); ScriptedVoiceTurn turn = _port.Turn(1); @@ -82,17 +80,15 @@ public async Task ATwoStepReply_ReportsATimeToFirstTokenPerStepAndOneFirstSpeech turn.End(); _ = await handle; - Assert.Equal([0.100, 0.150], readings.Of(FirstToken)); - Assert.Equal([0.150], readings.Of(FirstSpeech)); - Assert.Equal([0.600], readings.Of(ReplyEnd)); + Assert.Equal([0.100, 0.150], _latency.Of(LatencyMetric.TimeToFirstToken)); + Assert.Equal([0.150], _latency.Of(LatencyMetric.TimeToFirstSpeech)); + Assert.Equal([0.600], _latency.Of(LatencyMetric.TimeToReplyEnd)); } // EngineReplyStream.Start: no reading is taken once the speech is interrupted. [Fact(Timeout = 30_000)] public async Task ContentAfterTheSpeechWasInterrupted_TakesNoTimeToFirstTokenReading() { - using LatencyReadings readings = new(); - SpeechHandle handle = _activity.GenerateReply("Where is my order?", userTurnEndedAt: _time.GetTimestamp()); ScriptedVoiceTurn turn = _port.Turn(1); _ = await turn.Started; @@ -108,14 +104,13 @@ public async Task ContentAfterTheSpeechWasInterrupted_TakesNoTimeToFirstTokenRea turn.End(); _ = await handle; - Assert.Equal([0.100], readings.Of(FirstToken)); + Assert.Equal([0.100], _latency.Of(LatencyMetric.TimeToFirstToken)); } [Fact(Timeout = 30_000)] public async Task ABargeIn_ReportsTheTimeUntilTheSpeechIsDoneAndNoReplyEnd() { long userTurnEndedAt = _time.GetTimestamp(); - using LatencyReadings readings = new(); SpeechHandle handle = _activity.GenerateReply("Tell me a story.", userTurnEndedAt: userTurnEndedAt); ScriptedVoiceTurn turn = _port.Turn(1); @@ -134,10 +129,10 @@ public async Task ABargeIn_ReportsTheTimeUntilTheSpeechIsDoneAndNoReplyEnd() await interrupted; Assert.True(handle.IsDone); - Assert.Equal([0.120], readings.Of(BargeIn)); - Assert.Equal([0.080], readings.Of(FirstToken)); - Assert.Equal([0.080], readings.Of(FirstSpeech)); - Assert.Empty(readings.Of(ReplyEnd)); + Assert.Equal([0.120], _latency.Of(LatencyMetric.BargeIn)); + Assert.Equal([0.080], _latency.Of(LatencyMetric.TimeToFirstToken)); + Assert.Equal([0.080], _latency.Of(LatencyMetric.TimeToFirstSpeech)); + Assert.Empty(_latency.Of(LatencyMetric.TimeToReplyEnd)); } // agent_activity.py interrupt: the barge-in reading is taken once the interrupted speech is done, also when a @@ -145,8 +140,6 @@ public async Task ABargeIn_ReportsTheTimeUntilTheSpeechIsDoneAndNoReplyEnd() [Fact(Timeout = 30_000)] public async Task ABargeInAfterAFinalPrompt_ReportsOneReadingOnceTheSpeechIsDone() { - using LatencyReadings readings = new(); - SpeechHandle handle = _activity.GenerateReply("hi", userTurnEndedAt: _time.GetTimestamp()); ScriptedVoiceTurn turn = _port.Turn(1); _ = await turn.Started; @@ -163,27 +156,25 @@ public async Task ABargeInAfterAFinalPrompt_ReportsOneReadingOnceTheSpeechIsDone await Task.WhenAll(recorded, repeated); _ = await handle; - Assert.Equal([0.150], readings.Of(BargeIn)); + Assert.Equal([0.150], _latency.Of(LatencyMetric.BargeIn)); } // generation.py perform_llm_inference: ttft is measured from a step's start, so with no step there is none. [Fact] public void FirstContentBeforeAnyStep_ReportsNoTimeToFirstToken() { - using LatencyReadings readings = new(); - TurnMetrics metrics = new(_time, _time.GetTimestamp()); + TurnMetrics metrics = new(_time, _time.GetTimestamp(), _latency.Record); metrics.MarkFirstContent(); - Assert.Empty(readings.Of(FirstToken)); + Assert.Empty(_latency.Of(LatencyMetric.TimeToFirstToken)); } [Fact] public void EndingAReplyTwice_ReportsOneReplyEnd() { long userTurnEndedAt = _time.GetTimestamp(); - using LatencyReadings readings = new(); - TurnMetrics metrics = new(_time, userTurnEndedAt); + TurnMetrics metrics = new(_time, userTurnEndedAt, _latency.Record); _time.Advance(TimeSpan.FromMilliseconds(300)); metrics.MarkSpeechEnded(); @@ -192,7 +183,7 @@ public void EndingAReplyTwice_ReportsOneReplyEnd() metrics.MarkSpeechEnded(); metrics.EndReply(); - Assert.Equal([0.300], readings.Of(ReplyEnd)); + Assert.Equal([0.300], _latency.Of(LatencyMetric.TimeToReplyEnd)); } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs index 50235364..d501b4a3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs @@ -4,9 +4,12 @@ // Licensed under the Apache License, Version 2.0. Modified: translated to C#; driven over a scripted // engine turn instead of fake VAD/STT/LLM. -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -42,9 +45,9 @@ public ValueTask DisposeAsync() return _output.DisposeAsync(); } - // agent_activity.py:2552, 2774-2783 (B1): a final transcript interrupts the current speech, as audio activity, - // and with no report of what was heard the speech cuts its engine turn to the text it forwarded (design - // section 3) once the wait runs out; the next reply's turn follows it. + // agent_activity.py:2552, 2774-2783: a final transcript interrupts the current speech, as audio activity, + // and with no report of what was heard the speech cuts its engine turn to the text it forwarded + // once the wait runs out; the next reply's turn follows it. [Fact(Timeout = 30_000)] public async Task AFinalTranscript_InterruptsTheCurrentSpeechAndTheNextReplyAnswers() { @@ -72,7 +75,7 @@ public async Task AFinalTranscript_InterruptsTheCurrentSpeechAndTheNextReplyAnsw Assert.Equal(["Let me think about that.", "The second answer."], _output.Spoken); } - // agent_activity.py:2774-2780 (B4): an uninterruptible speech drops the turn instead of answering it. + // agent_activity.py:2774-2780: an uninterruptible speech drops the turn instead of answering it. [Fact(Timeout = 30_000)] public async Task AFinalTranscriptDuringAnUninterruptibleSpeech_DropsTheTurnAndLogsAWarning() { @@ -101,7 +104,7 @@ public async Task AFinalTranscriptDuringAnUninterruptibleSpeech_DropsTheTurnAndL } // test_agent_session.py::test_interim_transcript_interrupts_only_without_local_vad (VAD-present - // case only, :139): an interim prompt only sets state; it never interrupts (plan Q3, B8). + // case only, :139): an interim prompt only sets state; it never interrupts. [Fact(Timeout = 30_000)] public async Task AnInterimTranscript_SetsUserStateSpeakingAndNeverInterruptsTheCurrentSpeech() { diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs index 48ede5aa..1ddff75f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs @@ -2,9 +2,11 @@ // (_interrupt_by_audio_activity), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. // Licensed under the Apache License, Version 2.0. Modified: translated to C#; driven over a scripted engine turn. -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Cut; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -75,7 +77,7 @@ public async Task ABargeInWhileAReplyThatDisallowsInterruptionsWaitsOnATool_Cuts Assert.Equal(["Let me look that up.", "It shipped."], _output.Spoken); } - // Handoff 2026-09-22, step 7 open issues: the first cut wins, so a transport report that arrives once a + // The first cut wins, so a transport report that arrives once a // user turn has already interrupted the speech is refused. [Fact(Timeout = 30_000)] public async Task ABargeInOnAReplyAlreadyInterrupted_LeavesTheCutToThatInterruption() diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs new file mode 100644 index 00000000..1fb31623 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs @@ -0,0 +1,59 @@ +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Tests.DependencyInjection; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + public sealed class VoiceActivityHooksTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // With no sink injected, a reply's clocks and the session's states reach the + // conversation's hooks as notices, the readings stamped with the engine turn they belong to. + [Fact(Timeout = 60_000)] + public async Task AReplyAndABargeInReachTheConversationsHooksAsNotices() + { + RecordingHook hook = new(); + using StartedHost provider = await BuildAsync( + OneAgentYaml, + options => + { + _ = options.UseChatClients(_ => new RoutingChatClientFactory(new StallOnCueChatClient())); + _ = options.UseHooks(hook); + }); + ConversationSession conversation = provider.GetRequiredService().ForFactory("main").Create("c1"); + await using FakeConversationOutput output = new(); + VoiceSession voice = new(output, TimeProvider.System, NullLogger.Instance); + VoiceActivity activity = new(voice, conversation, CancellationToken.None); + + SpeechHandle answered = activity.GenerateReply("hello", userTurnEndedAt: voice.Time.GetTimestamp()); + _ = await answered; + SpeechHandle stalled = activity.GenerateReply(StallOnCueChatClient.Cue, userTurnEndedAt: voice.Time.GetTimestamp()); + await Poll.UntilAsync(() => output.Spoken.Any(text => text.Contains(StallOnCueChatClient.Piece, StringComparison.Ordinal))); + await activity.InterruptByAudioActivity(StallOnCueChatClient.Piece, TimeSpan.FromMilliseconds(100)); + _ = await stalled; + await conversation.FlushNoticesAsync(); + + IReadOnlyList latencies = hook.Of(); + Assert.Contains(latencies, notice => notice is { Metric: LatencyMetric.TimeToFirstToken, Scope.TurnIndex: 0 }); + Assert.Contains(latencies, notice => notice is { Metric: LatencyMetric.TimeToFirstSpeech, Scope.TurnIndex: 0 }); + Assert.Contains(latencies, notice => notice is { Metric: LatencyMetric.TimeToReplyEnd, Scope.TurnIndex: 0 }); + Assert.Contains(latencies, notice => notice is { Metric: LatencyMetric.TimeToFirstToken, Scope.TurnIndex: 1 }); + Assert.Contains(latencies, notice => notice is { Metric: LatencyMetric.BargeIn, Scope.TurnIndex: null }); + Assert.Contains(hook.Of(), change => change is { Party: VoiceParty.Agent, New: VoiceState.Speaking }); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs index 7cbed820..50953aa7 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs @@ -1,13 +1,22 @@ +using System.Threading.Channels; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Session; using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Ports; +using AgentCore.AspNetCore.Voice.Transport; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; @@ -16,7 +25,7 @@ namespace AgentCore.AspNetCore.Tests.Voice { - /// over real conversation sessions. + /// over real conversation sessions. public sealed class VoiceConversationLoopTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; @@ -84,7 +93,7 @@ public async Task AHangUpFromAStaleLoopLeavesANewerSessionOnTheSameIdUntouched() try { - RecordingObserver observer = new(); + RecordingHook hook = new(); using HeldPromptChatClient reply = new("unused", "unused"); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(TelnyxRelayTurnTests.PolicyYaml); RoutingChatClientFactory chatClients = new(reply); @@ -95,8 +104,8 @@ public async Task AHangUpFromAStaleLoopLeavesANewerSessionOnTheSameIdUntouched() new GuardEvaluator(compiled.Configuration.Guards), extractor: null, timeProvider: clock, - observers: [observer], - workspaceRoot: root); + workspaceRoot: root, + hooks: [hook]); using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), idleTimeout, clock); @@ -121,7 +130,8 @@ public async Task AHangUpFromAStaleLoopLeavesANewerSessionOnTheSameIdUntouched() Assert.True(File.Exists(sentinel), "the stale hang-up must not delete the newer session's folder."); Assert.Same(fresh, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Ct)); - Assert.DoesNotContain(ConversationEventKind.ConversationEnded, observer.Kinds); + await fresh.FlushNoticesAsync(); + Assert.Empty(hook.Of()); } finally { @@ -132,18 +142,128 @@ public async Task AHangUpFromAStaleLoopLeavesANewerSessionOnTheSameIdUntouched() } } - private static VoiceConversationLoop NewLoop(InMemoryConversationSessions sessions, TimeProvider clock, IConversationOutputPort output) + // Each line waits on a store lookup before it is raised, and the order of LineSpoken is the transcript a CRM + // keeps: a line whose lookup is slow must not be overtaken by the line said after it. + [Fact(Timeout = 30_000)] + public async Task TwoFinalPromptsBackToBackAreLinesInTheOrderTheyWereSaid() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("first reply", "second reply"); + using InMemoryConversationSessions inner = HookedSessions(reply, hook); + GatedConversationSessions sessions = new(inner); + FakeConversationOutput output = new(); + Channel inputs = Channel.CreateUnbounded(); + VoiceConversationLoop loop = NewLoop(sessions, TimeProvider.System, output); + Task running = loop.RunAsync(inputs.Reader.ReadAllAsync(Ct)); + + // The first prompt's keep-alive lookup passes; the lookup its line makes is held. + sessions.HoldTryGet(2); + _ = inputs.Writer.TryWrite(new ConversationInput.Started("conversation-lines")); + _ = inputs.Writer.TryWrite(new ConversationInput.Utterance("one", "en", IsFinal: true)); + await sessions.TryGetHeld.Task.WaitAsync(Ct); + _ = inputs.Writer.TryWrite(new ConversationInput.Utterance("two", "en", IsFinal: true)); + + // The loop reads in order, so once the barge after "two" reached the output, the line of "two" was handed on. + _ = inputs.Writer.TryWrite(new ConversationInput.Barge("first", TimeSpan.FromMilliseconds(100))); + await Poll.UntilAsync(() => output.Stops == 1); + sessions.ReleaseTryGet(); + _ = await hook.WaitForAsync(line => line.Text == "two").WaitAsync(Ct); + + Assert.Equal(["one", "two"], hook.Of().Where(line => line.Speaker == Speaker.Caller).Select(line => line.Text)); + + _ = inputs.Writer.TryComplete(); + await running; + await loop.DrainAsync(); + await loop.EndAsync(ConversationEndReason.CallerHungUp); + } + + // The connection ends the call right after the drain: a line still waiting on its store lookup must be raised + // before that, or the ended call drops it. + [Fact(Timeout = 30_000)] + public async Task TheDrainWaitsForTheLinesStillQueued() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("first reply", "second reply"); + using InMemoryConversationSessions inner = HookedSessions(reply, hook); + GatedConversationSessions sessions = new(inner); + Channel inputs = Channel.CreateUnbounded(); + VoiceConversationLoop loop = NewLoop(sessions, TimeProvider.System, new FakeConversationOutput()); + Task running = loop.RunAsync(inputs.Reader.ReadAllAsync(Ct)); + + _ = inputs.Writer.TryWrite(new ConversationInput.Started("conversation-drain")); + _ = inputs.Writer.TryWrite(new ConversationInput.Utterance("one", "en", IsFinal: true)); + _ = await hook.WaitForAsync(latency => latency.Metric == LatencyMetric.TimeToReplyEnd).WaitAsync(Ct); + _ = inputs.Writer.TryComplete(); + await running; + + // Nothing else reads the store now: the next lookup is the one the agent's line makes. + sessions.HoldTryGet(1); + Task drained = loop.DrainAsync(); + await sessions.TryGetHeld.Task.WaitAsync(Ct); + Assert.False(drained.IsCompleted, "the drain must wait for the line still on its way."); + sessions.ReleaseTryGet(); + await drained; + _ = await hook.WaitForAsync(line => line.Speaker == Speaker.Agent).WaitAsync(Ct); + await loop.EndAsync(ConversationEndReason.CallerHungUp); + + Assert.Equal( + [(Speaker.Caller, "one"), (Speaker.Agent, "first reply")], + hook.Of().Select(line => (line.Speaker, line.Text))); + } + + // The caller hangs up right after speaking: the connection is cancelled while the loop still touches the + // store for the words it already read. They are still a line, or the transcript loses the caller's last words. + [Fact(Timeout = 30_000)] + public async Task WordsReadBeforeTheConnectionWentAreStillALine() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("first reply", "second reply"); + using InMemoryConversationSessions inner = HookedSessions(reply, hook); + GatedConversationSessions sessions = new(inner); + using CancellationTokenSource connection = new(); + Channel inputs = Channel.CreateUnbounded(); + VoiceConversationLoop loop = NewLoop(sessions, TimeProvider.System, new FakeConversationOutput(), connection); + Task running = loop.RunAsync(inputs.Reader.ReadAllAsync(Ct)); + + sessions.HoldTryGet(1); + _ = inputs.Writer.TryWrite(new ConversationInput.Started("conversation-hung-up")); + _ = inputs.Writer.TryWrite(new ConversationInput.Utterance("okay bye", "en", IsFinal: true)); + await sessions.TryGetHeld.Task.WaitAsync(Ct); + await connection.CancelAsync(); + sessions.ReleaseTryGet(); + _ = inputs.Writer.TryComplete(); + + Assert.Null(await Record.ExceptionAsync(() => running)); + await loop.DrainAsync(); + _ = await hook.WaitForAsync(line => line.Speaker == Speaker.Caller).WaitAsync(Ct); + await loop.EndAsync(ConversationEndReason.CallerHungUp); + + Assert.Equal(["okay bye"], hook.Of().Where(line => line.Speaker == Speaker.Caller).Select(line => line.Text)); + } + + private static InMemoryConversationSessions HookedSessions(IChatClient reply, RecordingHook hook) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(TelnyxRelayTurnTests.PolicyYaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, new AgentCompilationContext(new RoutingChatClientFactory(reply)))[SingleEntrySessionFactories.MainEntry]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null, hooks: [hook]); + return new InMemoryConversationSessions(SingleEntrySessionFactories.Of(factory), TimeSpan.FromMinutes(5), TimeProvider.System); + } + + private static VoiceConversationLoop NewLoop( + IConversationSessions sessions, TimeProvider clock, IConversationOutputPort output, CancellationTokenSource? connection = null) { VoiceConversationLoop? loop = null; loop = new VoiceConversationLoop( - sessions, + new PhoneCallHost(sessions, HookRuntime.Empty, clock, NullLogger.Instance, GatePoint.BeforeCall.Deadline), SingleEntrySessionFactories.MainEntry, + "test-transport", output, new ConnectionTaskObserver(() => loop?.ConversationId ?? "(none)", (_, _) => { }, (_, _, _) => { }, (_, _, _) => false), clock, NullLogger.Instance, TimeSpan.FromSeconds(5), - CancellationToken.None, + connection?.Token ?? CancellationToken.None, new VoiceOptions(UserAway: null, new Dictionary())); return loop; } @@ -153,31 +273,5 @@ private static async IAsyncEnumerable OneAsync(ConversationIn await Task.Yield(); yield return input; } - - private sealed class RecordingObserver : IConversationObserver - { - private readonly List _kinds = []; - - public IReadOnlyList Kinds - { - get - { - lock (_kinds) - { - return [.. _kinds]; - } - } - } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken = default) - { - lock (_kinds) - { - _kinds.Add(conversationEvent.Kind); - } - - return ValueTask.CompletedTask; - } - } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceNoticesTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceNoticesTests.cs new file mode 100644 index 00000000..b0458710 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceNoticesTests.cs @@ -0,0 +1,39 @@ +using AgentCore.Application.Hooks.Engine; +using AgentCore.Application.Hooks.Notices; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + public sealed class VoiceNoticesTests + { + // Party, old → new, for the agent and for the user. + [Fact(Timeout = 10_000)] + public async Task EachStateChangeIsANotice() + { + RecordingHook hook = new(); + SessionHooks hooks = new(HookRuntime.Create([hook], loggers: null), "c1", "main", TimeProvider.System); + await using FakeConversationOutput output = new(); + VoiceSession session = new(output, new FakeTimeProvider(DateTimeOffset.UnixEpoch), NullLogger.Instance); + VoiceNotices.Attach(session, () => hooks); + + session.SetAgentState(AgentState.Thinking); + session.SetAgentState(AgentState.Speaking); + session.SetUserState(UserState.Speaking); + session.SetUserState(UserState.Away); + await hooks.FlushAsync(); + + Assert.Equal( + [ + (VoiceParty.Agent, VoiceState.Listening, VoiceState.Thinking), + (VoiceParty.Agent, VoiceState.Thinking, VoiceState.Speaking), + (VoiceParty.User, VoiceState.Listening, VoiceState.Speaking), + (VoiceParty.User, VoiceState.Speaking, VoiceState.Away), + ], + hook.Of().Select(change => (change.Party, change.Old, change.New))); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs index c8f6ea2e..14889aab 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs @@ -2,15 +2,15 @@ using System.Text.Json; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Filler; +using AgentCore.AspNetCore.Voice.Options; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice { /// /// providers.conversation's userAway and filler reach , - /// and a value a wait would refuse stops the start with a pointer (plan - /// step 8, owner rulings 1 and 2). + /// and a value a wait would refuse stops the start with a pointer. /// public sealed class VoiceOptionsBinderTests { diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs index a1f7fdc1..8c7715db 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs @@ -1,5 +1,5 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs index 106823d8..d8133ed0 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs @@ -7,14 +7,16 @@ // state transition, or a fresh one that started at the final transcript. using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Turns; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace AgentCore.AspNetCore.Tests.Voice { - /// When the caller counts as away (plan B7), and what a final and an interim transcript each do + /// When the caller counts as away, and what a final and an interim transcript each do /// to that window (LiveKit's _user_input_transcribed, agent_session.py:2254-2267). public sealed class VoiceSessionAwayTests : IAsyncDisposable { @@ -86,7 +88,7 @@ public async Task AFinalTranscriptWhileBothSidesListen_RestartsTheAwayWindow() // test_final_transcript_resets_away_timer_when_not_speaking (is_final=False half): an interim // transcript never calls the away-timer reset LiveKit's mock asserts on. Ours has no such reset // call for an interim either — OnInterimTranscript only ever moves the caller to "speaking", - // which cancels a pending countdown the same way any non-listening transition does (B8). + // which cancels a pending countdown the same way any non-listening transition does. [Fact] public void AnInterimTranscript_CancelsAPendingAwayCountdownInsteadOfResettingIt() { diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs index c495fbbb..ea327db0 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs @@ -1,5 +1,6 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs index 8b5c89ba..9e3a2114 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs @@ -1,5 +1,7 @@ using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Voice.Session; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; using AgentCore.TestSupport; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -35,8 +37,8 @@ public async Task Say_MovesAgentStateThroughThinkingSpeakingListening() List transitions = []; session.AgentStateChanged += change => transitions.Add(change.NewState); - // A filler or a notice plays while a pipeline reply's tool round has the agent "thinking" - // (B5); simulate that starting point directly, since generate_reply is step 5. + // A filler or a notice plays while a pipeline reply's tool round has the agent "thinking"; + // simulate that starting point directly. session.SetAgentState(AgentState.Thinking); SpeechHandle handle = session.Say("one moment"); diff --git a/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs b/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs index c5ae7dc8..786f934f 100644 --- a/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs +++ b/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs @@ -71,7 +71,7 @@ public void AnInterruption_RecordsWhatTheCallerHeardAndNotWhatTheModelProduced() string heard = interruption.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]; // The chain holds proof of the words and never the words, so the reviewer's check is against - // store 1: the amendment proves what the caller heard, and it is not the whole reply. + // the message store: the amendment proves what the caller heard, and it is not the whole reply. Assert.NotEqual(produced, heard); Assert.Equal(AuditHash.OfText(Heard).Value, heard); Assert.Equal(AuditHash.OfText(Spoken).Value, produced); diff --git a/tests/AgentCore.Domain.Tests/Audit/AuditHashTests.cs b/tests/AgentCore.Domain.Tests/Audit/AuditHashTests.cs index 35bd163d..2acb5b68 100644 --- a/tests/AgentCore.Domain.Tests/Audit/AuditHashTests.cs +++ b/tests/AgentCore.Domain.Tests/Audit/AuditHashTests.cs @@ -7,7 +7,7 @@ namespace AgentCore.Domain.Tests.Audit /// The digest the chain stores in place of the words a caller said or heard. /// /// - /// The words live in store 1 and stay erasable, so the chain must be able to prove a text it does + /// The words live in the message store and stay erasable, so the chain must be able to prove a text it does /// not hold. Both halves of that proof read the same bytes: this type, and /// encode(sha256(convert_to(t, 'UTF8')), 'hex') in PostgreSQL. /// diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs index c9390306..6c6ca9b3 100644 --- a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs @@ -1,4 +1,7 @@ using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Endpoints; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -87,11 +90,10 @@ public async Task ConversationRefusesAnEntryTheDocumentDoesNotDeclare() } [Fact] - public async Task ASelectorPicksTheEntryOnARouteThatNamesNone() + public async Task AHookPicksTheEntryOnARouteThatNamesNone() { - await using WebApplication app = await HostingTestHost.BuildAsync( - services: services => services.AddSingleton()); - _ = app.MapAgentCoreHost("/v1/chat/responses").Responses.SelectEntry(); + await using WebApplication app = await HostingTestHost.BuildAsync(configure: options => options.UseHooks(new MainEntry())); + _ = app.MapAgentCoreHost("/v1/chat/responses"); await app.StartAsync(TestContext.Current.CancellationToken); using HttpClient client = new() { BaseAddress = Address(app) }; @@ -102,7 +104,7 @@ public async Task ASelectorPicksTheEntryOnARouteThatNamesNone() } [Fact] - public async Task ARouteWithNoEntryParameterAndNoSelectorFailsTheStart() + public async Task ARouteWithNoEntryParameterAndNoEntryHookFailsTheStart() { await using WebApplication app = await HostingTestHost.BuildAsync(); _ = app.MapAgentCoreHost("/v1/chat/responses"); @@ -111,18 +113,7 @@ public async Task ARouteWithNoEntryParameterAndNoSelectorFailsTheStart() () => app.StartAsync(TestContext.Current.CancellationToken)); Assert.Contains("/v1/chat/responses", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task ASelectorTheHostNeverRegisteredFailsTheStart() - { - await using WebApplication app = await HostingTestHost.BuildAsync(); - _ = app.MapAgentCoreHost("/v1/chat/responses").Responses.SelectEntry(); - - ConfigurationLoadException failure = await Assert.ThrowsAsync( - () => app.StartAsync(TestContext.Current.CancellationToken)); - - Assert.Contains(nameof(UnregisteredSelector), failure.Message, StringComparison.Ordinal); + Assert.Contains(nameof(AgentHook.BeforeEntryAsync), failure.Message, StringComparison.Ordinal); } /// Builds a host, maps every route, and puts it on a real socket. @@ -163,20 +154,12 @@ private static Task PostEmptyAsync(HttpClient client, strin /// Picks the document's one entry for every caller. - private sealed class MainSelector : IEntrySelector - { - public ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken) - { - return ValueTask.FromResult("main"); - } - } - - /// A selector no test registers. - private sealed class UnregisteredSelector : IEntrySelector + private sealed class MainEntry : AgentHook { - public ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken) + public override ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) { - return ValueTask.FromResult("main"); + gate.Choose("main"); + return default; } } } diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs index c5beaa7e..21d4fd69 100644 --- a/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs @@ -26,24 +26,9 @@ namespace AgentCore.Hosting.Tests /// /// The two calls a host makes, and the promises they carry. /// - /// - /// - /// This library exists so a host owns no wiring, and the whole of that promise is that a host can - /// still overrule any part of it. Every test here is about who wins, because that is the one thing - /// the ordering inside Configure can get wrong without failing to compile: each Use* - /// seam is a setter, so defaults written after a host's callback would silently replace it. - /// - /// - /// Every test runs offline against a fake model. There is no OpenAI account, no network conversation, and - /// no API key anywhere in this file — which is itself the proof that the default vendor list costs - /// nothing until a document names it. - /// - /// public sealed class AgentCoreHostTests { - // --------------------------------------------------------------------------------------------- // Who wins. The host does, on every seam, because its callback runs last. - // --------------------------------------------------------------------------------------------- [Fact] public async Task AHostChatClientFactoryWinsOverTheDefaultVendor() @@ -65,9 +50,7 @@ public async Task AHostThatHandsOverADocumentDoesNotAlsoGetTheDefaultPath() Assert.NotNull(host.Services.GetService()); } - // --------------------------------------------------------------------------------------------- // The durable seams. Naming a vendor here is what makes providers.audit.kind: postgres startable. - // --------------------------------------------------------------------------------------------- [Fact] public async Task ProvidersAuditPostgresReachesTheAdapterRatherThanTheSelector() @@ -124,12 +107,10 @@ public async Task AHostConversationStoreWinsOverTheDefaultVendorOnTheSameKind() _ = Assert.IsType(host.Services.GetRequiredService().Store); } - // --------------------------------------------------------------------------------------------- // providers.knowledge.analyzer. The default QdrantKnowledgeAdapter is registered inside // Configure, before the host's own callback runs, so a host analyzer only reaches it if the // wiring reapplies it afterward in FinishConfiguring. If that ordering regresses, the failure // below reads "no registered IKnowledgeQueryAnalyzer" instead of a network error. - // --------------------------------------------------------------------------------------------- [Fact] public async Task AHostKnowledgeQueryAnalyzerReachesTheDefaultAdapter() @@ -165,18 +146,11 @@ public async Task AHostKnowledgePointMapperReachesTheDefaultAdapter() Assert.DoesNotContain("no registered", failure.Message, StringComparison.Ordinal); } - // --------------------------------------------------------------------------------------------- // The mcp: block. McpToolSource is registered from this project, not from AgentCore.AspNetCore — // this test is what actually proves that wiring runs a real connection attempt, naming the // server that failed to connect. - // --------------------------------------------------------------------------------------------- /// A document naming an mcp: server whose command does not exist. - /// - /// A missing executable fails Process.Start synchronously, so this reaches - /// immediately rather than waiting out any MCP - /// initialization timeout — the fast, offline failure this test needs. - /// private const string McpDocument = """ apiVersion: agentcore/v1 agents: @@ -220,9 +194,7 @@ public async Task AnMcpServerThatCannotBeReachedFailsTheStartNamingTheServer() Assert.Contains("no-such-server", failure.Message, StringComparison.Ordinal); } - // --------------------------------------------------------------------------------------------- // The CreateCase stub. It fills a gap and never takes a name the host wanted. - // --------------------------------------------------------------------------------------------- [Fact] public async Task AHostThatBindsNothingGetsTheStub() @@ -263,9 +235,7 @@ public async Task AHostThatBindsCreateCaseKeepsItsOwnDelegate() Assert.True((bool)Assert.IsType(result)["opened"]!); } - // --------------------------------------------------------------------------------------------- // What this extension registered, the container has to close. - // --------------------------------------------------------------------------------------------- [Fact] public async Task TheOutboundHttpPipelineClosesWithTheHost() @@ -291,11 +261,42 @@ public async Task TheLoggerFactoryClosesWithTheHost() // The container built this factory, so nothing else can be holding its providers. _ = Assert.Throws(() => loggers.CreateLogger("after")); } - // --------------------------------------------------------------------------------------------- - // Helpers. - // --------------------------------------------------------------------------------------------- + // The host's own callback runs after the turnkey wiring, so the resolver it sets is the one every + // vendor must read, openai-live included. + [Fact] + public async Task TheOpenAiLiveKeysResolveThroughTheResolverTheHostSet() + { + MapSecretResolver secrets = new MapSecretResolver() + .With(KnownSecrets.OpenAiApiKeyName, "sk-test") + .With(KnownSecrets.OpenAiWebhookSecretName, "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"); + + await using WebApplication host = await StartAsync(options => + { + options.Configuration = ConfigurationLoader.LoadYaml(LiveDocument); + options.SecretResolver = secrets; + }); + + Assert.Contains(KnownSecrets.OpenAiWebhookSecretName, secrets.Asked); + } /// The providers line that asks for the durable audit vendor. + private const string LiveDocument = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + entries: + main: + agent: only + providers: + conversation: + kind: openai-live + live: + instructions: "Be brief." + llm: + - { kind: fake, model: fake-model, as: reply } + """; + private const string Audit = " audit: { kind: postgres }"; /// The providers line that asks for the durable transcript vendor. @@ -322,10 +323,6 @@ public async Task TheLoggerFactoryClosesWithTheHost() /// Anything else the test says on the options. /// A further line under providers, or null for the plain document. /// The started host. - /// - /// It really starts: the document is read, and every adapter it names is opened, by the hosted - /// lifecycle service the composition root registers. Building alone opens nothing. - /// private static async Task StartAsync( Action? configure = null, string? providers = null) @@ -348,10 +345,6 @@ private static async Task StartAsync( } /// A chain that holds the connection string and answers nothing for it. - /// - /// A held-but-blank name fails without falling back, so these tests reach the same failure on a - /// machine that happens to export POSTGRES_CONNECTION_STRING. - /// private sealed class EmptySecretResolver : ISecretResolverPort { public ValueTask TryResolveAsync(string name, CancellationToken cancellationToken = default) @@ -393,11 +386,6 @@ public ValueTask OpenAsync( } /// An embedding vendor whose generator is never actually asked to embed anything. - /// - /// QdrantKnowledgeAdapter.CreateSearchAsync only needs a non-null generator to get past - /// its own providers.embeddings check; the analyzer resolves right after, and every test - /// using this adapter fails before either could reach the generator itself. - /// private sealed class FakeEmbeddingAdapter : IEmbeddingGeneratorAdapter { public const string ProviderKind = "hosting-tests-embed"; @@ -461,7 +449,6 @@ private sealed class StubPointMapper : IKnowledgePointMapper // A transport: http MCP server is reached on this host's own pipeline, minus the one part of it // that does not apply to a stream. - // --------------------------------------------------------------------------------------------- [Fact] public async Task AnMcpServerIsReachedOnThePipelinesOwnHandlerChain() diff --git a/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/PostgresAuditSinkTests.cs b/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/PostgresAuditSinkTests.cs index 6a434a5a..ac949d41 100644 --- a/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/PostgresAuditSinkTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/PostgresAuditSinkTests.cs @@ -7,7 +7,7 @@ namespace AgentCore.Infrastructure.Tests.Audit.Postgres { /// - /// Store 3 in PostgreSQL. + /// The audit store in PostgreSQL. /// /// /// These need a live PostgreSQL and skip without one — names the diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ConversationThreadSchemaTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ConversationThreadSchemaTests.cs index b78e428c..091cf559 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ConversationThreadSchemaTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ConversationThreadSchemaTests.cs @@ -4,7 +4,7 @@ namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { - /// Store 0's shape, and what the running role may do to it. + /// The conversation store's shape, and what the running role may do to it. /// /// conversation and role are both non-reserved keywords in PostgreSQL and need no quoting. That /// is what the first test proves: the migration would fail to apply at all if it were untrue. diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationEndTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationEndTests.cs new file mode 100644 index 00000000..2646a6b4 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationEndTests.cs @@ -0,0 +1,170 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Ports; +using AgentCore.Domain.Audit; +using AgentCore.Infrastructure.Conversation.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// A host end after the last turn, in PostgreSQL: the store keeps the end though no words carry it, so a later + /// session refuses the conversation's turns and does not start it again. + /// + public sealed class PostgresConversationEndTests : PostgresDatabaseTest + { + private const string OneAgentYaml = """ + apiVersion: agentcore/v1 + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + private const string StagedYaml = """ + apiVersion: agentcore/v1 + guards: + never: { ">=": [ { var: turnIndex }, 99 ] } + agents: + defaults: { clock: false } + items: + - { id: only, instructions: "ok" } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: never } ] } + - { id: done, agent: only, terminal: true } + """; + + /// + protected override bool Migrated => true; + + private IConversationStore Store => new PostgresConversationStore(DataSource); + + [PostgresFact] + public Task SaveState_LevelWithTheStoredState_ReplacesIt() + { + return ConversationStoreStateFacts.AStateLevelWithTheStoredOneReplacesIt(Store, Token); + } + + [PostgresFact] + public Task SaveState_BehindTheStoredState_IsDropped() + { + return ConversationStoreStateFacts.AStateBehindTheStoredOneIsDropped(Store, Token); + } + + [PostgresFact] + public Task SaveState_OfAConversationWithNoRow_LeavesItAlone() + { + return ConversationStoreStateFacts.AConversationWithNoRowIsLeftAlone(Store, Token); + } + + [PostgresTheory] + [InlineData(OneAgentYaml)] + [InlineData(StagedYaml)] + public async Task AHostEndAfterAFinishedTurn_RefusesTheNextTurnAfterAReload(string yaml) + { + // Arrange + IConversationStore store = Store; + ConversationSession first = Create(yaml, store, hook: null); + _ = await first.RunTurnAsync("hi", Token); + Assert.True(first.EndConversation(ConversationEndReason.CallerHungUp)); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + RecordingHook hook = new(); + ConversationSession second = Create(yaml, store, hook); + + // Act + _ = await Assert.ThrowsAsync(() => second.RunTurnAsync("are you there?", Token)); + + // Assert + await second.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + } + + [PostgresTheory] + [InlineData(OneAgentYaml)] + [InlineData(StagedYaml)] + public async Task AHostEndBeforeAnyTurnOfAReloadedSession_RefusesTheNextTurnAfterAnotherReload(string yaml) + { + // Arrange + IConversationStore store = Store; + ConversationSession first = Create(yaml, store, hook: null); + _ = await first.RunTurnAsync("hi", Token); + await first.FlushTranscriptAsync(); + await first.DisposeAsync(); + + ConversationSession second = Create(yaml, store, hook: null); + Assert.True(second.EndConversation(ConversationEndReason.CallerHungUp)); + await second.FlushTranscriptAsync(); + await second.DisposeAsync(); + + RecordingHook hook = new(); + ConversationSession third = Create(yaml, store, hook); + + // Act + _ = await Assert.ThrowsAsync(() => third.RunTurnAsync("are you there?", Token)); + + // Assert + await third.FlushNoticesAsync(); + Assert.Empty(hook.Of()); + } + + private static ConversationSession Create(string yaml, IConversationStore store, AgentHook? hook) + { + RoutingChatClientFactory models = new(new HelloChatClient()); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), + new AgentCompilationContext(models) { ConversationStore = store, Hooks = hook is null ? null : [hook] })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create("conversation-1"); + } + + /// A model that says hello to every request. + private sealed class HelloChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "hello") { MessageId = Guid.NewGuid().ToString("N") }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs index bee6f8be..66ee0ca4 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs @@ -6,7 +6,7 @@ namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { - /// Store 0, in PostgreSQL. + /// The conversation store, in PostgreSQL. public sealed class PostgresConversationStoreTests : PostgresDatabaseTest { /// diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTruncateTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTruncateTests.cs index 56fe622f..bd1f611d 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTruncateTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTruncateTests.cs @@ -10,7 +10,7 @@ namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { /// - /// What an edit does to store 1 in PostgreSQL: which rows go, which stay, and what the names the + /// What an edit does to the message store in PostgreSQL: which rows go, which stay, and what the names the /// caller knows its messages by are good for. /// public sealed class PostgresConversationStoreTruncateTests : PostgresDatabaseTest @@ -51,7 +51,7 @@ public async Task Truncate_TakesTheNamedOrdinalAndEverythingAfterIt() public async Task Truncate_LeavesTheNextAppendFreeToClimbPastTheGap() { // The ordinals a truncation takes are never issued again, so an append after one leaves a - // hole. Nothing may object to that: store 3 keeps audit rows against the turns that stood in + // hole. Nothing may object to that: the audit store keeps audit rows against the turns that stood in // the gap, and reissuing a number would put two turns in one place in the chain. PostgresConversationStore store = await OpenAsync(); _ = await store.AppendAsync( diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWindowTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWindowTests.cs index 247c7c0d..da97faa9 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWindowTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWindowTests.cs @@ -6,7 +6,7 @@ namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { - /// Reading store 1 in PostgreSQL a window of turns at a time: newest turns first, every turn whole. + /// Reading the message store in PostgreSQL a window of turns at a time: newest turns first, every turn whole. public sealed class PostgresConversationStoreWindowTests : PostgresDatabaseTest { /// diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs index 37454b44..99b8d539 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs @@ -12,7 +12,7 @@ namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { /// - /// Store 1 against store 3: what + /// The conversation store against the audit store: what /// rebuilds from the words, beside the hash the audit chain holds for them. /// public sealed class PostgresConversationVerificationTests : PostgresDatabaseTest @@ -56,7 +56,7 @@ public async Task ReadSpokenTurnsAsync_AToolCallingTurn_MatchesTheHashTheChainHo // Act IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); - // Assert — what the nightly check does: hash the words store 1 holds, and compare. + // Assert — what the nightly check does: hash the words the conversation store holds, and compare. Assert.Equal(AuditHash.OfText(turns[0].Spoken).Value, turns[0].ReplyTextSha256); } @@ -137,7 +137,43 @@ await store.RewriteAsync( Assert.NotEqual(AuditHash.OfText(after.Spoken).Value, after.ReplyTextSha256); } - /// Writes a tool-calling turn to store 1 and its turn.completed row to store 3. + // A phone turn writes what the vendor's front voice said ahead of the caller's words; the chain hashes only + // the agent's reply, so the rebuild must leave those lines out, found by the author stored in the jsonb. + [PostgresFact] + public async Task ReadSpokenTurnsAsync_ATurnThatCarriesFrontVoiceLines_MatchesTheHashOfTheAgentsReplyOnly() + { + PostgresConversationStore store = await OpenAsync(); + _ = await store.AppendAsync( + "C1", + [ + new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "do you know what day it is today"), "m0"), + new ConversationMessageDraft(0, new ChatMessage(ChatRole.Assistant, "It's Wednesday.") { AuthorName = "front_voice" }, "m1"), + new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "please end the call now"), "m2"), + new ConversationMessageDraft(0, new ChatMessage(ChatRole.Assistant, "Today is Sunday. Goodbye!"), "m3"), + ], + cancellationToken: Token); + await new PostgresAuditSink(DataSource).AppendAsync( + new AuditEvent + { + ConversationId = "C1", + EventId = Guid.CreateVersion7(), + Kind = AuditEventKind.TurnCompleted, + OccurredAt = new DateTimeOffset(2026, 10, 4, 9, 0, 1, TimeSpan.Zero), + TurnIndex = 0, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText("Today is Sunday. Goodbye!").Value, + }, + }, + Token); + + TranscriptTurnDigest turn = Assert.Single(await store.ReadSpokenTurnsAsync("C1", Token)); + + Assert.Equal("Today is Sunday. Goodbye!", turn.Spoken); + Assert.Equal(AuditHash.OfText(turn.Spoken).Value, turn.ReplyTextSha256); + } + + /// Writes a tool-calling turn to the conversation store and its turn.completed row to the audit store. private async Task WriteToolCallingTurnAsync( PostgresConversationStore store, string conversationId, int turnIndex, string spoken, bool cited = false) { @@ -190,8 +226,8 @@ await chain.AppendAsync( } /// - /// Writes a two-step turn to store 1 — the tool announced on the same message as its call, then the - /// reply once the result is in — and its turn.completed row to store 3, hashed over both steps. + /// Writes a two-step turn to the conversation store — the tool announced on the same message as its call, then the + /// reply once the result is in — and its turn.completed row to the audit store, hashed over both steps. /// private async Task WriteMultiStepTurnAsync( PostgresConversationStore store, string conversationId, int turnIndex, string announced, string spoken) diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs index 976fac80..c9b7df15 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs @@ -3,13 +3,14 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; using AgentCore.Domain; using AgentCore.Infrastructure.Conversation.Postgres; using AgentCore.Infrastructure.Tests.Database.Postgres; using AgentCore.TestSupport; using Xunit; +using AgentCore.Application.Runtime.Cut; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs index 8498fc3c..d70ac0e9 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs @@ -4,7 +4,7 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; -using AgentCore.Application.Runtime; +using AgentCore.Application.Hooks; using AgentCore.Domain.Audit; using AgentCore.Infrastructure.Audit.Postgres; using AgentCore.Infrastructure.Conversation.Postgres; @@ -12,11 +12,12 @@ using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { /// - /// A real turn's words in store 1 and its events in store 3, checked the way the store's verify query checks + /// A real turn's words in the conversation store and its events in the audit store, checked the way the store's verify query checks /// them: the words the rows say must hash to what the chain proves, whatever way the turn ended. /// public sealed class PostgresTurnVerificationTests : PostgresDatabaseTest @@ -98,14 +99,15 @@ private static ConversationSession Create( return new ConversationSessionFactory( compiled, new GuardEvaluator(compiled.Configuration.Guards), - observers: ConversationObservers.Standard(events, logger: null)).Create(); + hooks: BuiltInHooks.Create(events)).Create(); } - /// Files the session's events in store 3 and runs the store's verify query over turn 0. + /// Files the session's events in the audit store and runs the store's verify query over turn 0. private async Task VerifyAsync( PostgresConversationStore store, InMemoryAuditSink events, ConversationSession session) { await session.FlushTranscriptAsync(); + await session.FlushNoticesAsync(); // Not disposed: the sink would take the test's own pool with it. PostgresAuditSink chain = new(DataSource); diff --git a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs index 6b1cbcf2..b1e54731 100644 --- a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs @@ -6,7 +6,7 @@ namespace AgentCore.Infrastructure.Tests.Database.Postgres { /// - /// The PostgreSQL schema store 1 and store 3 run on. + /// The PostgreSQL schema the message store and the audit store run on. /// public sealed class PostgresSchemaTests : PostgresDatabaseTest { diff --git a/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs b/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs index 6e2db7cc..c9a437d4 100644 --- a/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.State; using AgentCore.Domain.Knowledge; using AgentCore.Infrastructure.Knowledge.VectorData.Qdrant; @@ -14,35 +13,14 @@ using Qdrant.Client; using Qdrant.Client.Grpc; using Xunit; +using AgentCore.Application.Runtime.Clarification; +using AgentCore.Application.Runtime.Session; namespace AgentCore.Infrastructure.Tests.Knowledge.VectorData.Qdrant { /// - /// Section 12's "probe -- against real Qdrant" list. + /// The ambiguity probe against a real Qdrant server. /// - /// , and - /// are internal to AgentCore.Application, and this project carries no grant to reach them. Every - /// row here is instead driven through the same seam a production host uses: a real YAML document - /// compiled by with a real handed - /// in as , and a real turn. The - /// turn's own "reply" model is a fake that calls the compiled agent's own Search tool with the - /// row's question, the way a model that needs the cards would; the framework's own tool-calling loop - /// then runs the real search with the running turn. Reading the tool's answer back off the transcript - /// is what keeps every row on the production path: the loop's own flow is only entered around - /// ConversationSession's call into the reply model. - /// - /// - /// The store fuses a plain dense leg with a required-term leg by RRF (K49's amendment): a card the - /// required-term leg does not match can still surface through the dense leg alone, and - /// QdrantKnowledgeStoreTests.SearchAsync_LookalikeIdentifier_RanksTheIdentifierCardFirst already - /// pins that a matching identifier only lifts a card to the top rank, never excludes a non-matching - /// one. A required-term token therefore cannot isolate a single card from the shared corpus by itself; - /// the row that needs exactly one card (the two-machine card) combines a distinctive token with a - /// store Limit of 1, so only the fused winner survives. Every row that needs a card population - /// this shared fixture must not carry for every other row -- the company-wide card, the one-machine - /// multi-card union, and the empty-search row -- builds and drops its own small collection instead. - /// - /// public sealed class AmbiguityCorpusFixture : IAsyncLifetime { /// The id of the added card whose facets.model names two machines. @@ -74,9 +52,9 @@ public async ValueTask InitializeAsync() Client = QdrantServer.CreateClient(); // The 30-card synthetic corpus, unmodified, plus the multi-machine card the brief conversations for. - // Composing with KbShapedCorpus rather than inventing a second one: A4's own facet-read tests + // Composing with KbShapedCorpus rather than inventing a second one: the facet-read tests // already proved this corpus's payload shape works, and a wrong facet path is this design's - // own central failure mode (section 12), so reusing the shape that is already proven is the + // own central failure mode, so reusing the shape that is already proven is the // point. The "*" card lives in its own throwaway collection instead (see // CompanyWideQuestion_ExactlyOneMatchingCard_Answers_ProbeDoesNotRun): giving every card here // model: "*" would make it the only card any scope: { model: "*" } search could ever find, @@ -185,7 +163,7 @@ public sealed class AmbiguityIntegrationTests(AmbiguityCorpusFixture corpus) : I private const string NoticeSourceName = "agentcore:notice"; - /// Two droppable-shaped facets: model (the one the probe drops) and the always-concrete audience, so K33 never blocks the drop. + /// Two droppable-shaped facets: model (the one the probe drops) and the always-concrete audience, so the probe is never skipped for having a single facet. private const string TwoFacetYaml = """ apiVersion: agentcore/v1 @@ -225,7 +203,7 @@ public sealed class AmbiguityIntegrationTests(AmbiguityCorpusFixture corpus) : I agent: only """; - /// One droppable-shaped facet only: dropping it would open the scope empty (K33). + /// One droppable-shaped facet only: dropping it would open the scope empty. private const string SingleFacetYaml = """ apiVersion: agentcore/v1 @@ -273,18 +251,8 @@ public sealed class AmbiguityIntegrationTests(AmbiguityCorpusFixture corpus) : I private readonly AmbiguityCorpusFixture _corpus = corpus; - // ----------------------------------------------------------------------------------------- - // Section 12's twelve real-Qdrant rows. - // ----------------------------------------------------------------------------------------- /// A company-wide question with exactly one matching card answers, and the probe does not run. - /// - /// This card's own facets.model is the wildcard value, so a scope: { model: "*" } - /// search finds it under any query. It cannot live in the shared 31-card fixture: it would then be - /// the one card every other row's "unknown model" scope can always find too, since that scope - /// filter only ever admits a card whose own facet is literally "*". A throwaway collection, built - /// and dropped here, is what keeps this row's fact from leaking into every other one. - /// [QdrantFact] public async Task CompanyWideQuestion_ExactlyOneMatchingCard_Answers_ProbeDoesNotRun() { @@ -324,14 +292,6 @@ public async Task PerMachineQuestion_ProducesTheNote_NamingAllThreeIds() } /// A question whose cards all belong to one machine produces the one-candidate confirm text, not "holds nothing". - /// - /// §12's row names "cards", plural: several cards must actually reach the probe's union and - /// collapse to one value, not a single card standing in for the whole row (that shape is already - /// KnowledgeProbeTests.K24_OneCardIsNeverASpread's job, at the unit level). Four cards, all - /// carrying facets.model: ct900, live in their own throwaway collection so the union is - /// exactly {ct900} by real collapsing over multiple cards, not because there was nothing else to - /// find. - /// [QdrantFact] public async Task QuestionWhoseCardsAllBelongToOneMachine_ProducesTheOneCandidateConfirmText() { @@ -414,7 +374,7 @@ public async Task NoNoticeCanBeCitedAsACard() Assert.All(named.Results, r => Assert.Equal(NoticeSourceName, r.SourceName)); } - /// A single-facet fromState deployment does not throw: the probe is skipped and the turn returns "holds nothing" (K33). + /// A single-facet fromState deployment does not throw: the probe is skipped and the turn returns "holds nothing". [QdrantFact] public async Task SingleFacetFromStateDeployment_DoesNotThrow_ProbeIsSkipped_HoldsNothing() { @@ -423,14 +383,7 @@ public async Task SingleFacetFromStateDeployment_DoesNotThrow_ProbeIsSkipped_Hol Assert.Contains("holds nothing", Assert.Single(client.Results).Text, StringComparison.Ordinal); } - /// An unscoped agent's empty search returns an empty list (§8 step 2). - /// - /// An unscoped agent opens WholeCorpus -- no facets at all -- so the store's own filter is - /// empty and admits every point. Against the shared fixture that would always find something (the - /// closest card by dense rank, regardless of the query's own text, since a fixed test embedding - /// answers every query alike); a genuinely empty collection is what makes the search answer - /// nothing, and is the one true test of "empty", independent of ranking. - /// + /// An unscoped agent's empty search returns an empty list. [QdrantFact] public async Task UnscopedAgent_EmptySearch_ReturnsAnEmptyList() { @@ -452,14 +405,8 @@ public async Task UnscopedAgent_EmptySearch_ReturnsAnEmptyList() /// /// A probe that throws returns "holds nothing", logs its own event, and leaves the main search's - /// audit record intact (K32). + /// audit record intact. /// - /// - /// The real store answers the main search; only the probe's own narrowed second search is a - /// synthetic throw, because the two searches share every scope facet but the one being dropped, so - /// there is no real Qdrant misconfiguration that fails one and not the other (verified by reading - /// QdrantKnowledgeStore's filter construction before choosing this shape). - /// [QdrantFact] public async Task ProbeThatThrows_ReturnsHoldsNothing_LogsItsOwnEvent_LeavesTheMainSearchAuditRecordIntact() { @@ -483,9 +430,6 @@ public async Task ProbeThatThrows_ReturnsHoldsNothing_LogsItsOwnEvent_LeavesTheM Assert.Empty(loggers.Of(12)); } - // ----------------------------------------------------------------------------------------- - // Helpers. - // ----------------------------------------------------------------------------------------- private async Task RunAsync( string yaml, @@ -549,16 +493,8 @@ private QdrantKnowledgeStore BuildStoreOver(string collection, int limit, bool s /// /// Creates and fills one throwaway collection, already named by the caller, for one test. Used by /// the rows whose own card population must not be visible to any other row's search of the shared - /// fixture (see 's remarks). + /// fixture. /// - /// - /// The caller generates and opens its own try **before** - /// calling this -- never the reverse. Collection creation, every index, and the upsert can each - /// throw against a live, sometimes-contended server, and if that happened inside an un-tried - /// helper the collection this conversation already created on the server would be orphaned with nothing - /// left to drop it. Creation, indexing and upsert all sit inside one try, with one - /// unconditional DeleteCollectionAsync in the caller's own finally. - /// private async Task FillAdHocCollectionAsync( string collection, PointStruct[] points, bool scoped) { @@ -589,7 +525,7 @@ await _corpus.Client.CreateCollectionAsync( /// /// A knowledge store that answers the full-scope (main) search from a real backing store and - /// throws for any narrowed one -- the shape §8 step 4's own probe search takes once a facet is + /// throws for any narrowed one -- the shape the probe's own search takes once a facet is /// dropped. Used only for the row that needs the probe's own second search to fail. /// private sealed class ThrowingOnNarrowedScopePort : IKnowledgeRetrievalPort diff --git a/tests/AgentCore.TestSupport/ConversationStoreStateFacts.cs b/tests/AgentCore.TestSupport/ConversationStoreStateFacts.cs new file mode 100644 index 00000000..21b65dc7 --- /dev/null +++ b/tests/AgentCore.TestSupport/ConversationStoreStateFacts.cs @@ -0,0 +1,70 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using Microsoft.Extensions.AI; + +namespace AgentCore.TestSupport +{ + /// + /// What every store must do with a state no words carry (): keep it + /// when it is level with the stored state or newer, and drop it when it is behind. Each store's test class runs + /// these against its own instance. A fact that fails throws . + /// + public static class ConversationStoreStateFacts + { + [AssertionMethod] + public static async Task AStateLevelWithTheStoredOneReplacesIt(IConversationStore store, CancellationToken cancellationToken) + { + await SeedAsync(store, cancellationToken); + + await store.SaveStateAsync("c1", new ConversationSessionState { NextTurnIndex = 1, Stage = "done", IsComplete = true }, cancellationToken); + + Expect("1/done/True", await StoredAsync(store, cancellationToken), "the state after a level write"); + } + + [AssertionMethod] + public static async Task AStateBehindTheStoredOneIsDropped(IConversationStore store, CancellationToken cancellationToken) + { + await SeedAsync(store, cancellationToken); + + await store.SaveStateAsync("c1", new ConversationSessionState { NextTurnIndex = 0, Stage = "done", IsComplete = true }, cancellationToken); + + Expect("1/working/False", await StoredAsync(store, cancellationToken), "the state after a write behind it"); + } + + [AssertionMethod] + public static async Task AConversationWithNoRowIsLeftAlone(IConversationStore store, CancellationToken cancellationToken) + { + await store.SaveStateAsync("missing", new ConversationSessionState { NextTurnIndex = 1, IsComplete = true }, cancellationToken); + + if (await store.GetAsync("missing", cancellationToken) is not null) + { + throw new InvalidOperationException("a state write made a conversation that had no row."); + } + } + + private static async Task SeedAsync(IConversationStore store, CancellationToken cancellationToken) + { + _ = await store.CreateAsync("c1", cancellationToken); + _ = await store.AppendAsync( + "c1", + [new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "hi"), "m-0-u")], + new ConversationSessionState { NextTurnIndex = 1, Stage = "working" }, + cancellationToken); + } + + private static async Task StoredAsync(IConversationStore store, CancellationToken cancellationToken) + { + ConversationSessionState? state = (await store.GetAsync("c1", cancellationToken))?.State; + return state is null ? "none" : $"{state.NextTurnIndex}/{state.Stage}/{state.IsComplete}"; + } + + private static void Expect(string expected, string actual, string what) + { + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"{what}: expected {expected}, saw {actual}."); + } + } + } +} diff --git a/tests/AgentCore.TestSupport/ConversationStoreSummaryRowFacts.cs b/tests/AgentCore.TestSupport/ConversationStoreSummaryRowFacts.cs index 33e1aa68..d1019b9f 100644 --- a/tests/AgentCore.TestSupport/ConversationStoreSummaryRowFacts.cs +++ b/tests/AgentCore.TestSupport/ConversationStoreSummaryRowFacts.cs @@ -5,7 +5,7 @@ namespace AgentCore.TestSupport { /// - /// What every store 1 must do with a summary row: hand it to the session's read alone, leave it out + /// What every message store must do with a summary row: hand it to the session's read alone, leave it out /// of every consumer read, and take it on a truncate only when the cut reaches a row it covers. /// Each store's test class runs these against its own instance. This project carries no test /// framework, so a fact that fails throws naming what it saw. diff --git a/tests/AgentCore.TestSupport/DelegatingConversationStore.cs b/tests/AgentCore.TestSupport/DelegatingConversationStore.cs index d0dcbaad..a3cc8c41 100644 --- a/tests/AgentCore.TestSupport/DelegatingConversationStore.cs +++ b/tests/AgentCore.TestSupport/DelegatingConversationStore.cs @@ -74,6 +74,13 @@ public virtual ValueTask DeleteAsync(string conversationId, CancellationToken ca return Inner.DeleteAsync(conversationId, cancellationToken); } + /// + public virtual ValueTask SaveStateAsync( + string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default) + { + return Inner.SaveStateAsync(conversationId, state, cancellationToken); + } + /// public virtual ValueTask> AppendAsync( string conversationId, diff --git a/tests/AgentCore.TestSupport/FakeTimeProvider.cs b/tests/AgentCore.TestSupport/FakeTimeProvider.cs index 01bc6895..3458cb00 100644 --- a/tests/AgentCore.TestSupport/FakeTimeProvider.cs +++ b/tests/AgentCore.TestSupport/FakeTimeProvider.cs @@ -3,23 +3,6 @@ namespace AgentCore.TestSupport /// /// A clock a test owns, including every timer a production seam schedules against it. /// - /// - /// - /// A seam that bounds a deadline with Task.Delay(delay, timeProvider, cancellationToken) — - /// the relay connection's idle deadline is the case this was written for — arms - /// its completion through , not through - /// . A fake that only overrode GetUtcNow would leave - /// that timer running on the real clock underneath it, so a test would still have to sleep for the - /// real delay. Overriding here is what lets fire a - /// due timer synchronously, with no wall-clock wait at all. - /// - /// - /// A timer behaves as does. A one-shot timer that fired, or one - /// armed with , is not scheduled but not disposed either: - /// arms it again. reschedules a periodic timer for its - /// next due time. The timer counts only include scheduled timers. - /// - /// public sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider { private readonly Lock _gate = new(); @@ -27,6 +10,11 @@ public sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider private readonly List<(DateTimeOffset? DueAt, int Count, TaskCompletionSource Reached)> _waiters = []; private DateTimeOffset _now = start; + /// Gets the zone this clock calls local; the machine's own unless a test names one. + public TimeZoneInfo Zone { get; init; } = TimeZoneInfo.Local; + + public override TimeZoneInfo LocalTimeZone => Zone; + public override DateTimeOffset GetUtcNow() { lock (_gate) @@ -35,11 +23,6 @@ public override DateTimeOffset GetUtcNow() } } - /// - /// Overridden with , because the base implementation reads - /// and would leave every elapsed-time measurement - /// taken against this fake running on the real clock underneath it. - /// public override long TimestampFrequency => TimeSpan.TicksPerSecond; public override long GetTimestamp() @@ -93,12 +76,6 @@ private Task WaitForCountAsync(DateTimeOffset? dueAt, int count) /// Moves the clock forward, and fires every timer whose due time this reaches or passes. /// How far to move the clock. - /// - /// Firing happens outside the lock: a timer's own callback here is - /// , which can run a caller's registered - /// callback synchronously, and nothing this class does may still hold - /// when a caller's own code starts running under it. - /// public void Advance(TimeSpan delta) { List due; diff --git a/tests/AgentCore.TestSupport/GatedTool.cs b/tests/AgentCore.TestSupport/GatedTool.cs new file mode 100644 index 00000000..16de1ea3 --- /dev/null +++ b/tests/AgentCore.TestSupport/GatedTool.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.TestSupport +{ + /// + /// A tool that counts its runs and holds each one until completes. A run whose token is + /// cancelled while it waits ends there, completes , and never counts as finished. + /// + public sealed class GatedTool + { + /// What every finished run returns. + public const string Result = """{"price":42}"""; + + private int _runs; + + private int _finished; + + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Cancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int Runs => Volatile.Read(ref _runs); + + public int Finished => Volatile.Read(ref _finished); + + public AIFunction Create(string name, string description) => AIFunctionFactory.Create(RunAsync, name, description); + + private async Task RunAsync(CancellationToken cancellationToken) + { + _ = Interlocked.Increment(ref _runs); + _ = Entered.TrySetResult(); + try + { + await Release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _ = Cancelled.TrySetResult(); + throw; + } + + _ = Interlocked.Increment(ref _finished); + return Result; + } + } +} diff --git a/tests/AgentCore.TestSupport/RecordingHook.cs b/tests/AgentCore.TestSupport/RecordingHook.cs new file mode 100644 index 00000000..e63b5306 --- /dev/null +++ b/tests/AgentCore.TestSupport/RecordingHook.cs @@ -0,0 +1,106 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Notices; + +namespace AgentCore.TestSupport +{ + /// + /// A hook that keeps every notice it is handed, in delivery order. It never times out, so a test that + /// flushes sees everything. + /// + public sealed class RecordingHook : AgentHook + { + private readonly Lock _gate = new(); + private readonly List _notices = []; + private readonly List<(Func Match, TaskCompletionSource Seen)> _waiters = []; + + public IReadOnlyList Notices + { + get + { + lock (_gate) + { + return [.. _notices]; + } + } + } + + public override TimeSpan? NoticeTimeout => null; + + public IReadOnlyList Of() + where T : HookNotice + { + return [.. Notices.OfType()]; + } + + /// Completes with the first notice of type that matches, seen before or after the call. + public async Task WaitForAsync(Func? match = null) + where T : HookNotice + { + TaskCompletionSource seen = new(TaskCreationOptions.RunContinuationsAsynchronously); + bool Matches(HookNotice notice) => notice is T typed && (match?.Invoke(typed) ?? true); + + lock (_gate) + { + if (_notices.FirstOrDefault(Matches) is { } already) + { + return (T)already; + } + + _waiters.Add((Matches, seen)); + } + + return (T)await seen.Task.ConfigureAwait(false); + } + + public override ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => Record(notice); + + private ValueTask Record(HookNotice notice) + { + List> done = []; + lock (_gate) + { + _notices.Add(notice); + foreach ((Func Match, TaskCompletionSource Seen) waiter in _waiters.Where(w => w.Match(notice)).ToList()) + { + _ = _waiters.Remove(waiter); + done.Add(waiter.Seen); + } + } + + foreach (TaskCompletionSource seen in done) + { + _ = seen.TrySetResult(notice); + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.TestSupport/TwoGatedCallsChatClient.cs b/tests/AgentCore.TestSupport/TwoGatedCallsChatClient.cs new file mode 100644 index 00000000..3ebab6f5 --- /dev/null +++ b/tests/AgentCore.TestSupport/TwoGatedCallsChatClient.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.TestSupport +{ + /// + /// Calls every tool it is offered in one round, then replies with each call's result as + /// call_N=result lines, one per call in call-id order, so a test reads what the model was told about each call. + /// + public sealed class TwoGatedCallsChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + List results = [.. messages.SelectMany(message => message.Contents.OfType())]; + if (results.Count == 0 && options?.Tools?.OfType().ToList() is { Count: > 0 } tools) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [.. tools.Select((tool, index) => new FunctionCallContent( + $"call_{index + 1}", + tool.Name, + new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" }))]); + yield break; + } + + yield return new ChatResponseUpdate( + ChatRole.Assistant, + string.Join('\n', results.OrderBy(result => result.CallId, StringComparer.Ordinal).Select(result => $"{result.CallId}={result.Result}"))); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} From ffa081a494bdfb28f4bf629e4f337a416255a558 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 4 Oct 2026 18:54:10 -0600 Subject: [PATCH 2/6] feat: enhance GPT-Live integration with greeting support and configuration updates --- config/example.yaml | 17 +++++++++++++++++ .../Schema/agentcore-v1.schema.json | 2 +- .../Vendors/OpenAiLive/Call/LiveInstructions.cs | 16 ++-------------- .../Vendors/OpenAiLive/Call/OpenAiLiveCall.cs | 12 +++++++++--- .../Vendors/OpenAiLive/OpenAiLiveSettings.cs | 17 +++++++++++++---- .../OpenAiLive/Webhook/OpenAiLiveWebhook.cs | 2 +- .../Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs | 3 +-- .../Fakes/OpenAiLiveHost.cs | 1 + .../Fakes/RunningLiveCall.cs | 7 +++++-- .../Vendors/OpenAiLive/LiveInstructionsTests.cs | 15 ++++----------- .../Vendors/OpenAiLive/OpenAiLiveCallTests.cs | 4 ++-- .../Vendors/OpenAiLive/OpenAiLiveClockTests.cs | 4 +--- .../OpenAiLive/OpenAiLiveSettingsTests.cs | 11 ++++++++++- .../OpenAiLive/OpenAiLiveWebhookTests.cs | 13 ++++--------- 14 files changed, 71 insertions(+), 53 deletions(-) diff --git a/config/example.yaml b/config/example.yaml index 9ebb473d..ea253d7b 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -364,6 +364,23 @@ providers: # conversation the media pipe. It carries the audio and owns the route /v1/{entry}/call. # speech recognition (stt) and synthesis (tts), one role at a time. conversation: { kind: telnyx-relay } + # The GPT-Live phone path instead. It speaks for itself, so it needs no speech: block. AgentCore adds + # no prompt text of its own: GPT-Live hears only what this block says, plus the host's per-call brief. + # Without a "never guess, delegate" paragraph GPT-Live made up product facts in 4 of 6 test calls, + # and it cannot hang up, so tell it to pass an end request to the backend. + # conversation: + # kind: openai-live + # live: + # instructions: | # required: GPT-Live's whole prompt + # You are the phone voice of the backend. Be brief. + # You know no facts yourself. For any question that needs a fact, delegate to the backend and + # wait for its answer. Never guess a fact. + # You cannot end the call yourself. When the caller wants to end or hang up the call, do not + # say goodbye. Delegate it to the backend right away, then speak only the backend's answer. + # greeting: "Greet the caller as your instructions say, then pause and listen." + # # optional: without it GPT-Live waits for the caller to speak + # voice: marin # optional, default marin + # model: gpt-live-1 # optional, default gpt-live-1 speech: stt: { kind: telnyx-relay } # bundled with the relay, so it matches conversation tts: { kind: telnyx-relay } # bundled with the relay, so it matches conversation diff --git a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json index a9ef166b..84ea9bbf 100644 --- a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json +++ b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json @@ -1363,7 +1363,7 @@ "description": "The largest inbound frame the socket accepts. The ceiling is int.MaxValue, which is the largest buffer length the socket can be given." }, "answerSeconds": { "type": "integer", "minimum": 1, "maximum": 60, "description": "How long the call gate gives each hook to accept or reject an offered call, in seconds. Omit it for 5." }, - "live": { "type": "object", "description": "The openai-live vendor's own settings: instructions, voice, model. Only that adapter reads and checks them." }, + "live": { "type": "object", "description": "The openai-live vendor's own settings: instructions, greeting, voice, model. Only that adapter reads and checks them." }, "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.", diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs index 1e3680fc..fd665f19 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveInstructions.cs @@ -1,25 +1,13 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { - /// - /// GPT-Live's own instructions: the entry's configured prompt, the fixed paragraph that keeps it from guessing - /// facts (a plain prompt guessed in 4 of 6 test calls, this paragraph delegated in 6 of 6) and from saying - /// goodbye on a call only the backend can hang up (a real SIP call: asked to end the call, GPT-Live said goodbye - /// itself and the line stayed up), and the host's brief, appended exactly as written. - /// + /// GPT-Live's instructions: the entry's configured prompt, then the host's brief, appended exactly as written. internal static class LiveInstructions { - internal const string NeverGuess = - "You know no product facts yourself. For any question about products, specifications, orders," - + " parts, or policies, delegate to the backend and wait for its answer. Never guess a fact." - + " You cannot end the call yourself: when the caller wants to end or hang up the call, delegate that" - + " to the backend, and say goodbye with its answer."; - internal static string Build(string configured, string? brief) { ArgumentNullException.ThrowIfNull(configured); - string instructions = configured.TrimEnd() + "\n\n" + NeverGuess; - return brief is null ? instructions : instructions + "\n\n" + brief; + return brief is null ? configured : configured.TrimEnd() + "\n\n" + brief; } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index d359e207..6837f8db 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -12,9 +12,11 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { /// /// One live GPT-Live call: reads the sideband, keeps the transcript ledger, and answers each delegation through the - /// call core. Talks to the engine only through . + /// call core. Talks to the engine only through . A greeting leaves GPT-Live + /// silent until the caller speaks. /// - internal sealed class OpenAiLiveCall(PhoneCall call, ILiveSideband sideband, Func> hangUp, ILogger logger) : IDisposable + internal sealed class OpenAiLiveCall( + PhoneCall call, ILiveSideband sideband, Func> hangUp, ILogger logger, string? greeting = null) : IDisposable { internal const string ShutdownCause = "shutdown"; @@ -80,7 +82,11 @@ internal async Task RunAsync(CancellationToken cancellationToken) await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false); } - await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId()), cancellationToken).ConfigureAwait(false); + if (greeting is not null) + { + await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), greeting), cancellationToken).ConfigureAwait(false); + } + while (true) { receiving ??= sideband.ReceiveAsync(cancellationToken).AsTask(); diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs index 016787ff..5ce2a7aa 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs @@ -6,10 +6,15 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive { /// The providers.conversation.live block, read and checked at boot. - /// GPT-Live's own prompt: tone and greeting. + /// AgentCore adds no prompt text of its own: every word GPT-Live is told to say comes from this block. + /// GPT-Live's whole prompt, sent as written. /// The voice GPT-Live speaks with. /// The GPT-Live model. - internal sealed record OpenAiLiveSettings(string Instructions, string Voice, string Model) + /// + /// The instruction GPT-Live gets once the call is up, such as "Greet the caller as your instructions say", or + /// : GPT-Live then stays silent until the caller speaks. + /// + internal sealed record OpenAiLiveSettings(string Instructions, string Voice, string Model, string? Greeting = null) { internal const string DefaultVoice = "marin"; @@ -30,6 +35,7 @@ internal static OpenAiLiveSettings From(ConversationProviderConfiguration config string? instructions = null; string? voice = null; string? model = null; + string? greeting = null; foreach (JsonProperty property in configuration.Live.EnumerateObject()) { switch (property.Name) @@ -43,17 +49,20 @@ internal static OpenAiLiveSettings From(ConversationProviderConfiguration config case "model": model = Text(property); break; + case "greeting": + greeting = Text(property); + break; default: throw Fail( "/" + property.Name, - $"providers.conversation.live.{property.Name} is not a setting of the openai-live vendor. It reads instructions, voice, and model. " + $"providers.conversation.live.{property.Name} is not a setting of the openai-live vendor. It reads instructions, greeting, voice, and model. " + $"The OpenAI key and the webhook secret come from the secret resolver as '{KnownSecrets.OpenAiApiKeyName}' and '{KnownSecrets.OpenAiWebhookSecretName}'."); } } return string.IsNullOrWhiteSpace(instructions) ? throw Fail("/instructions", "providers.conversation.live.instructions is missing or empty. GPT-Live needs its own prompt.") - : new OpenAiLiveSettings(instructions, voice ?? DefaultVoice, model ?? DefaultModel); + : new OpenAiLiveSettings(instructions, voice ?? DefaultVoice, model ?? DefaultModel, greeting); } private static string Text(JsonProperty property) diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs index fcbf3f3b..6d9d9127 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -119,7 +119,7 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming return false; } - calls.Run(new OpenAiLiveCall(call, sideband, _ => control.HangupQuietlyAsync(incoming.CallId), logger)); + calls.Run(new OpenAiLiveCall(call, sideband, _ => control.HangupQuietlyAsync(incoming.CallId), logger, settings.Greeting)); return true; } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs index b619911a..740e5249 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs @@ -68,8 +68,7 @@ internal static Uri AttachUri(Uri apiBase, string callId) /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: instructions with no /// delegation, from OpenAI's live-conversations guide. A real SIP call without it stayed silent until the caller spoke. /// - internal static JsonObject GreetFirst(string eventId) => - SessionFact(eventId, "Begin the conversation now: greet the caller as your instructions say, then pause and listen."); + internal static JsonObject GreetFirst(string eventId, string greeting) => SessionFact(eventId, greeting); /// /// A fact for the whole session, under no delegation, from OpenAI's delegation guide: at most 500 tokens, such as diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs index dddbd677..2fc8cee9 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs @@ -45,6 +45,7 @@ internal sealed class OpenAiLiveHost : IAsyncDisposable kind: openai-live live: instructions: "Be brief." + greeting: "Say hello." llm: - { kind: openai, model: gpt-4.1-mini, as: reply } entries: diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs index abf17a50..0ca03ede 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs @@ -17,6 +17,8 @@ internal sealed class RunningLiveCall : IAsyncDisposable { public static readonly DateTimeOffset Noon = new(2026, 10, 4, 12, 0, 0, TimeSpan.Zero); + public const string Greeting = "Greet the caller, then pause and listen."; + private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCall call, FakeSideband sideband) { Harness = harness; @@ -40,7 +42,8 @@ private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCa public TaskCompletionSource HungUp { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public static async Task StartAsync( - IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null) + IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null, + string? greeting = Greeting) { CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(Noon) { Zone = TimeZoneInfo.Utc }; @@ -48,7 +51,7 @@ public static async Task StartAsync( PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), ct)).Call!; await call.StartAsync(ct); RunningLiveCall running = new(harness, time, call, sideband ?? new FakeSideband()); - OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger ?? NullLogger.Instance); + OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger ?? NullLogger.Instance, greeting); running.Loop = live.RunAsync(ct); return running; } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs index 9e8642cd..c7173293 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveInstructionsTests.cs @@ -3,20 +3,13 @@ namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive { - /// The strict paragraph is always added; the brief is appended verbatim. + /// The configured prompt goes out as written; the brief is appended verbatim. public sealed class LiveInstructionsTests { - // The paragraph that made GPT-Live delegate in 6 of 6 probe runs, word for word. - private const string Strict = - "You know no product facts yourself. For any question about products, specifications, orders," - + " parts, or policies, delegate to the backend and wait for its answer. Never guess a fact." - + " You cannot end the call yourself: when the caller wants to end or hang up the call, delegate that" - + " to the backend, and say goodbye with its answer."; - [Fact] - public void TheConfiguredPromptAlwaysGetsTheStrictParagraph() + public void WithNoBriefTheConfiguredPromptIsSentAsWritten() { - Assert.Equal("You are a phone support agent. Be brief.\n\n" + Strict, LiveInstructions.Build("You are a phone support agent. Be brief.\n", brief: null)); + Assert.Equal("You are a phone support agent. Be brief.\n", LiveInstructions.Build("You are a phone support agent. Be brief.\n", brief: null)); } [Fact] @@ -24,7 +17,7 @@ public void TheBriefIsAppendedExactlyAsTheHostWroteIt() { const string brief = "Earlier summary, for context only.\n \"Ask first.\" "; - Assert.Equal("Be brief.\n\n" + Strict + "\n\n" + brief, LiveInstructions.Build("Be brief.", brief)); + Assert.Equal("Be brief.\n\n" + brief, LiveInstructions.Build("Be brief.\n", brief)); } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs index 23c793be..74b24c1a 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs @@ -146,7 +146,7 @@ public async Task ASidebandThatFailsMidLineStillRaisesTheOpenLine() } // GPT-Live waits for the caller's voice unless it is told to speak first (OpenAI's live-conversations guide): - // a session.instructions.append with no delegation, before anything else. + // the configured greeting as a session.instructions.append with no delegation, before anything else. [Fact(Timeout = 30_000)] public async Task TheCallTellsGptLiveToGreetTheCallerFirst() { @@ -157,7 +157,7 @@ public async Task TheCallTellsGptLiveToGreetTheCallerFirst() Assert.Same(greet, running.Sideband.Sent[0]); Assert.True(greet.ContainsKey("delegation_id")); Assert.Null(greet["delegation_id"]); - Assert.False(string.IsNullOrWhiteSpace((string?)greet["content"])); + Assert.Equal(RunningLiveCall.Greeting, (string?)greet["content"]); Assert.False(string.IsNullOrWhiteSpace((string?)greet["event_id"])); } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs index bb06f736..772cccc2 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs @@ -32,8 +32,6 @@ public sealed class OpenAiLiveClockTests agent: only """; - private const string Greeting = "Begin the conversation now: greet the caller as your instructions say, then pause and listen."; - // RunningLiveCall's clock stands at 2026-10-04 12:00 UTC, a Sunday. [Fact(Timeout = 30_000)] public async Task TheCallTellsGptLiveTheDateAndTimeBeforeTheGreeting() @@ -61,6 +59,6 @@ public async Task AnEntryAgentWithClockOffTellsGptLiveNoTime() Assert.Same(greeting, running.Sideband.Sent[0]); } - private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == Greeting; + private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == RunningLiveCall.Greeting; } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs index 28e21202..016134ca 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs @@ -5,7 +5,7 @@ namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive { - /// The live: block holds instructions, voice and model, and nothing else. + /// The live: block holds instructions, greeting, voice and model, and nothing else. public sealed class OpenAiLiveSettingsTests { [Fact] @@ -22,10 +22,19 @@ public void AVoiceAndAModelAreTaken() OpenAiLiveSettings.From(Conversation("live: { instructions: \"Be brief.\", voice: cedar, model: gpt-live-2 }"))); } + [Fact] + public void AGreetingIsTakenAsWritten() + { + Assert.Equal( + new OpenAiLiveSettings("Be brief.", "marin", "gpt-live-1", "Say hello, then listen."), + OpenAiLiveSettings.From(Conversation("live: { instructions: \"Be brief.\", greeting: \"Say hello, then listen.\" }"))); + } + [Theory] [InlineData("live: { voice: marin }", "/providers/conversation/live/instructions")] [InlineData("live: { instructions: \"x\", webhookSecret: \"${secret:OPENAI_WEBHOOK_SECRET}\" }", "/providers/conversation/live/webhookSecret")] [InlineData("answerSeconds: 5", "/providers/conversation/live")] + [InlineData("live: { instructions: \"x\", greeting: \"\" }", "/providers/conversation/live/greeting")] public void AWrongBlockFailsWithAPointer(string block, string errorPath) { ConfigurationLoadException failure = Assert.Throws(() => OpenAiLiveSettings.From(Conversation(block))); diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs index f36d7ced..151210c4 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs @@ -16,13 +16,6 @@ public sealed class OpenAiLiveWebhookTests { private const string Brief = "Earlier summary, for context only.\n\"Ask first.\""; - // The accept payload a real call accepted, word for word. - private const string Strict = - "You know no product facts yourself. For any question about products, specifications, orders," - + " parts, or policies, delegate to the backend and wait for its answer. Never guess a fact." - + " You cannot end the call yourself: when the caller wants to end or hang up the call, delegate that" - + " to the backend, and say goodbye with its answer."; - [Fact(Timeout = 30_000)] public async Task AnAcceptedCallIsAcceptedWithItsInstructionsVoiceAndClientDelegationThenAttached() { @@ -31,7 +24,8 @@ public async Task AnAcceptedCallIsAcceptedWithItsInstructionsVoiceAndClientDeleg new StallOnCueChatClient(), [hook, new CallDecider(gate => gate.Accept("cw_1_call_" + gate.CallId, Brief))]); HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123")); - _ = await host.FirstAttach.Task; + FakeSideband sideband = await host.FirstAttach.Task; + JsonObject greet = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.instructions.append"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); RecordingLiveControl.Seen accept = Assert.Single(host.Control.Requests); @@ -39,10 +33,11 @@ public async Task AnAcceptedCallIsAcceptedWithItsInstructionsVoiceAndClientDeleg // A real call refused anything else: "Invalid Live SIP accept payload: session must be the only field." Assert.Equal(["session"], accept.Body!.AsObject().Select(field => field.Key)); JsonNode session = accept.Body["session"]!; - Assert.Equal("Be brief.\n\n" + Strict + "\n\n" + Brief, (string?)session["instructions"]); + Assert.Equal("Be brief.\n\n" + Brief, (string?)session["instructions"]); Assert.Equal("marin", (string?)session["audio"]!["output"]!["voice"]); Assert.Equal("gpt-live-1", (string?)session["model"]); Assert.Equal("client", (string?)session["delegation"]!["type"]); + Assert.Equal("Say hello.", (string?)greet["content"]); CallStarted started = await hook.WaitForAsync(); Assert.Equal(("rtc_123", "+15550100", "openai-live", "cw_1_call_rtc_123"), (started.CallId, started.From, started.Transport, started.Scope.ConversationId)); } From b11d7b658d5052a0f1c9dc8a9da3209ee2a37e2c Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 4 Oct 2026 19:41:43 -0600 Subject: [PATCH 3/6] feat: implement conversation action framework with control interface and action results --- .../Actions/ConversationAction.cs | 13 ++ .../Actions/ConversationActionResult.cs | 18 +++ .../Actions/EndConversationAction.cs | 11 ++ .../Actions/IConversationControl.cs | 14 ++ .../Conversation/Actions/TransferAction.cs | 27 ++++ .../PublicAPI.Unshipped.txt | 44 +++++++ .../Runtime/Agents/ConversationTurnRunner.cs | 2 + .../Runtime/Harness/BackgroundChildAgent.cs | 3 +- .../Runtime/Session/ConversationActions.cs | 42 ++++++ .../Runtime/Session/ConversationSession.cs | 31 ++--- .../Runtime/Session/IConversationChannel.cs | 11 ++ .../Session/UnattachedConversationControl.cs | 21 +++ .../Runtime/ToolCalls/ToolCallScopes.cs | 7 +- .../Runtime/Turn/TurnInvocation.cs | 4 + .../Tools/ToolCallScope.cs | 9 ++ .../Vendors/OpenAiLive/OpenAiLiveSettings.cs | 7 +- .../Conversation/ConversationActionsTests.cs | 124 ++++++++++++++++++ .../Tools/ToolCallScopeTests.cs | 52 +++++--- 18 files changed, 399 insertions(+), 41 deletions(-) create mode 100644 src/AgentCore.Application/Conversation/Actions/ConversationAction.cs create mode 100644 src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs create mode 100644 src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs create mode 100644 src/AgentCore.Application/Conversation/Actions/IConversationControl.cs create mode 100644 src/AgentCore.Application/Conversation/Actions/TransferAction.cs create mode 100644 src/AgentCore.Application/Runtime/Session/ConversationActions.cs create mode 100644 src/AgentCore.Application/Runtime/Session/IConversationChannel.cs create mode 100644 src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs create mode 100644 tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs new file mode 100644 index 00000000..4a92ab37 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs @@ -0,0 +1,13 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// + /// The base of every action a conversation can be asked for. Its constructor is closed to other assemblies: every + /// channel must know what each action means, and an action a host made up would reach channels that cannot read it. + /// + public abstract record ConversationAction + { + private protected ConversationAction() + { + } + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs b/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs new file mode 100644 index 00000000..c2739a33 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// + /// The answer to an action request. It is a value and not an exception, so the tool can turn a refusal into words + /// for the model; AgentCore writes no text for the model. + /// + public enum ConversationActionResult + { + /// Accepted. It runs after the current answer was delivered, so a caller hears the answer first. + Scheduled = 0, + + /// The channel has no way to do it, such as a web chat asked to transfer a call. The tool picks the fallback. + NotSupported = 1, + + /// Refused: an end was already asked for, and a later action would contradict it. + Ending = 2, + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs new file mode 100644 index 00000000..4e354bdd --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs @@ -0,0 +1,11 @@ +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Conversation.Actions +{ + /// + /// The same end as ConversationSession.EndConversation, as an action, so a tool ends its conversation through + /// the one door it uses for every other action. + /// + /// Stored as the conversation.ended token, so it becomes part of the audit record. + public sealed record EndConversationAction(ConversationEndReason Reason) : ConversationAction; +} diff --git a/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs b/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs new file mode 100644 index 00000000..75966561 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs @@ -0,0 +1,14 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// + /// One door for every action, so a new action needs no new method here and no new parameter on a tool. + /// + public interface IConversationControl + { + /// Returns at once and runs nothing inline, because an action must wait until the current answer was delivered. + /// One of the actions AgentCore defines. + /// Whether the action will run, and if not, why, so the tool can tell the model. + /// is . + ConversationActionResult Request(ConversationAction action); + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs b/src/AgentCore.Application/Conversation/Actions/TransferAction.cs new file mode 100644 index 00000000..37145e4f --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/TransferAction.cs @@ -0,0 +1,27 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// + /// Hands a phone call to another line. It waits until the caller heard the current answer, because a transfer that + /// cuts the reply loses what the caller was just told. + /// + public sealed record TransferAction : ConversationAction + { + /// Checks the target here, so a bad target fails in the tool that made it and not later on the call. + /// A tel:, sip: or sips: URI, the forms a SIP REFER can carry. + /// is . + /// is not an absolute tel, sip or sips URI. + public TransferAction(Uri target) + { + ArgumentNullException.ThrowIfNull(target); + if (!target.IsAbsoluteUri || target.Scheme is not ("tel" or "sip" or "sips")) + { + throw new ArgumentException($"A transfer target must be a tel:, sip: or sips: URI, not '{target}'.", nameof(target)); + } + + Target = target; + } + + /// Gets the target. It is a URI and not a phone number, so a PSTN number and a SIP endpoint both fit. + public Uri Target { get; } + } +} diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index 765c2947..be39bb6d 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -3114,3 +3114,47 @@ override sealed AgentCore.Application.Hooks.Notices.CallEnded.Equals(AgentCore.A static AgentCore.Application.Hooks.Notices.CallEnded.operator !=(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool static AgentCore.Application.Hooks.Notices.CallEnded.operator ==(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool virtual AgentCore.Application.Hooks.AgentHook.OnCallEndedAsync(AgentCore.Application.Hooks.Notices.CallEnded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Actions.ConversationAction +AgentCore.Application.Conversation.Actions.ConversationAction.ConversationAction(AgentCore.Application.Conversation.Actions.ConversationAction! original) -> void +AgentCore.Application.Conversation.Actions.ConversationActionResult +AgentCore.Application.Conversation.Actions.ConversationActionResult.Ending = 2 -> AgentCore.Application.Conversation.Actions.ConversationActionResult +AgentCore.Application.Conversation.Actions.ConversationActionResult.NotSupported = 1 -> AgentCore.Application.Conversation.Actions.ConversationActionResult +AgentCore.Application.Conversation.Actions.ConversationActionResult.Scheduled = 0 -> AgentCore.Application.Conversation.Actions.ConversationActionResult +AgentCore.Application.Conversation.Actions.EndConversationAction +AgentCore.Application.Conversation.Actions.EndConversationAction.Deconstruct(out AgentCore.Domain.Audit.ConversationEndReason Reason) -> void +AgentCore.Application.Conversation.Actions.EndConversationAction.EndConversationAction(AgentCore.Domain.Audit.ConversationEndReason Reason) -> void +AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(AgentCore.Application.Conversation.Actions.EndConversationAction? other) -> bool +AgentCore.Application.Conversation.Actions.EndConversationAction.Reason.get -> AgentCore.Domain.Audit.ConversationEndReason +AgentCore.Application.Conversation.Actions.EndConversationAction.Reason.init -> void +AgentCore.Application.Conversation.Actions.IConversationControl +AgentCore.Application.Conversation.Actions.IConversationControl.Request(AgentCore.Application.Conversation.Actions.ConversationAction! action) -> AgentCore.Application.Conversation.Actions.ConversationActionResult +AgentCore.Application.Conversation.Actions.TransferAction +AgentCore.Application.Conversation.Actions.TransferAction.Equals(AgentCore.Application.Conversation.Actions.TransferAction? other) -> bool +AgentCore.Application.Conversation.Actions.TransferAction.Target.get -> System.Uri! +AgentCore.Application.Conversation.Actions.TransferAction.TransferAction(System.Uri! target) -> void +AgentCore.Application.Runtime.Session.ConversationSession.Request(AgentCore.Application.Conversation.Actions.ConversationAction! action) -> AgentCore.Application.Conversation.Actions.ConversationActionResult +AgentCore.Application.Tools.ToolCallScope.Conversation.get -> AgentCore.Application.Conversation.Actions.IConversationControl! +AgentCore.Application.Tools.ToolCallScope.Conversation.init -> void +abstract AgentCore.Application.Conversation.Actions.ConversationAction.$() -> AgentCore.Application.Conversation.Actions.ConversationAction! +override AgentCore.Application.Conversation.Actions.ConversationAction.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Actions.ConversationAction.GetHashCode() -> int +override AgentCore.Application.Conversation.Actions.ConversationAction.ToString() -> string! +override AgentCore.Application.Conversation.Actions.EndConversationAction.$() -> AgentCore.Application.Conversation.Actions.EndConversationAction! +override AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Actions.EndConversationAction.GetHashCode() -> int +override AgentCore.Application.Conversation.Actions.EndConversationAction.ToString() -> string! +override AgentCore.Application.Conversation.Actions.TransferAction.$() -> AgentCore.Application.Conversation.Actions.TransferAction! +override AgentCore.Application.Conversation.Actions.TransferAction.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Actions.TransferAction.GetHashCode() -> int +override AgentCore.Application.Conversation.Actions.TransferAction.ToString() -> string! +override sealed AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool +override sealed AgentCore.Application.Conversation.Actions.TransferAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool +static AgentCore.Application.Conversation.Actions.ConversationAction.operator ==(AgentCore.Application.Conversation.Actions.ConversationAction? left, AgentCore.Application.Conversation.Actions.ConversationAction? right) -> bool +static AgentCore.Application.Conversation.Actions.ConversationAction.operator !=(AgentCore.Application.Conversation.Actions.ConversationAction? left, AgentCore.Application.Conversation.Actions.ConversationAction? right) -> bool +static AgentCore.Application.Conversation.Actions.EndConversationAction.operator ==(AgentCore.Application.Conversation.Actions.EndConversationAction? left, AgentCore.Application.Conversation.Actions.EndConversationAction? right) -> bool +static AgentCore.Application.Conversation.Actions.EndConversationAction.operator !=(AgentCore.Application.Conversation.Actions.EndConversationAction? left, AgentCore.Application.Conversation.Actions.EndConversationAction? right) -> bool +static AgentCore.Application.Conversation.Actions.TransferAction.operator ==(AgentCore.Application.Conversation.Actions.TransferAction? left, AgentCore.Application.Conversation.Actions.TransferAction? right) -> bool +static AgentCore.Application.Conversation.Actions.TransferAction.operator !=(AgentCore.Application.Conversation.Actions.TransferAction? left, AgentCore.Application.Conversation.Actions.TransferAction? right) -> bool +virtual AgentCore.Application.Conversation.Actions.ConversationAction.EqualityContract.get -> System.Type! +virtual AgentCore.Application.Conversation.Actions.ConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool +virtual AgentCore.Application.Conversation.Actions.ConversationAction.PrintMembers(System.Text.StringBuilder! builder) -> bool diff --git a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs index bb4b1e1c..a83a1a3d 100644 --- a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs +++ b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs @@ -143,6 +143,7 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, // An edit that withdraws the turn that asked leaves its requests unanswered: the edit drops them. IReadOnlySet withdrawn = TurnApprovalAnswers.Withdrawn(_session.History, session, origin); + ChatMessage spoken = !userInput.Contents.OfType().Any() && PendingApprovalQueue.Open(session).Where(request => !withdrawn.Contains(request.RequestId)).ToList() is { Count: > 0 } open ? TurnApprovalAnswers.MovedOn(userInput, open) @@ -264,6 +265,7 @@ internal TurnInvocation TurnInvocationOf(ConversationTurn turn, ITurnCompleter c ToolRuns = _session.ToolRuns, Notices = new TurnNotices(), Hooks = _session.Hooks, + Control = _session.Actions, RefusalReply = _session.Compiled.RefusalReply, Items = new ConcurrentDictionary(StringComparer.Ordinal), Rounds = new TurnRounds(), diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs index 1d4cb431..5adc44a8 100644 --- a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs +++ b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs @@ -38,7 +38,7 @@ protected override async ValueTask CreateSessionCoreAsync(Cancella /// /// The part of the parent's turn a child may share: the conversation, its workspace folder, its shells, so - /// files: and shell: on the child work on the same disk, and the end backstop and count of its tools. Every drain, + /// files: and shell: on the child work on the same disk, the end backstop and count of its tools, and the conversation's action door. Every drain, /// tool list, and clarification stays with the parent; a child's publish reports a /// link in its result instead. /// @@ -55,6 +55,7 @@ private static TurnInvocation Shared(TurnInvocation parent) TimeZone = parent.TimeZone, ToolStop = parent.ToolStop, ToolRuns = parent.ToolRuns, + Control = parent.Control, Nested = true, }; } diff --git a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs b/src/AgentCore.Application/Runtime/Session/ConversationActions.cs new file mode 100644 index 00000000..a14f989b --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/ConversationActions.cs @@ -0,0 +1,42 @@ +using AgentCore.Application.Conversation.Actions; + +namespace AgentCore.Application.Runtime.Session +{ + /// + /// The end is handled here and not by the channel, because every channel already follows an end the engine asks for. + /// Only an action the transport must perform itself goes to the channel. + /// + internal sealed class ConversationActions(ConversationSession session) : IConversationControl + { + private IConversationChannel? _channel; + + /// Replaces any earlier channel, because a call that takes a conversation over carries its actions from then on. + internal void Attach(IConversationChannel channel) + { + ArgumentNullException.ThrowIfNull(channel); + Volatile.Write(ref _channel, channel); + } + + /// Compare-and-clear, because an older call can detach after a newer call attached, and must not drop it. + internal void Detach(IConversationChannel channel) + { + _ = Interlocked.CompareExchange(ref _channel, null, channel); + } + + /// + public ConversationActionResult Request(ConversationAction action) + { + ArgumentNullException.ThrowIfNull(action); + if (session.IsComplete || session.Lifetime.Ending.Requested) + { + return ConversationActionResult.Ending; + } + + return action switch + { + EndConversationAction end => session.Lifetime.EndConversation(end.Reason) ? ConversationActionResult.Scheduled : ConversationActionResult.Ending, + _ => Volatile.Read(ref _channel)?.Request(action) ?? ConversationActionResult.NotSupported, + }; + } + } +} diff --git a/src/AgentCore.Application/Runtime/Session/ConversationSession.cs b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs index c3caa162..bad66753 100644 --- a/src/AgentCore.Application/Runtime/Session/ConversationSession.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs @@ -3,6 +3,7 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Actions; using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Policy; @@ -102,6 +103,8 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal FinishedToolPairs ToolPairs { get; } + internal ConversationActions Actions { get; } + /// /// Creates the session of one conversation. /// @@ -116,29 +119,17 @@ internal ConversationSession( (CompiledAgent? compiled, IGuardEvaluator? guards, StateExtractor? extractor, TimeProvider? timeProvider, ILogger? logger, HookRuntime? hooks) = seams; ConversationId = conversationId; - Logger = logger ?? NullLogger.Instance; - WorkspaceRoot = workspace; - Shells = workspace is null ? null : new ConversationShells(workspace.Path, Logger); - Compiled = compiled; - History = compiled.History; - SessionCarriesHistory = compiled.SessionCarriesHistory; - Extractor = extractor; - Counters = new CounterStateWriter(guards); - Time = timeProvider; - Hooks = new SessionHooks(hooks, conversationId, compiled.EntryName, timeProvider); - StartedAt = timeProvider.GetUtcNow(); - Policy = compiled.Policy is null ? null : compiled.CreatePolicy(guards); State = new StateDocument(compiled.Configuration, Policy?.Stage); @@ -146,24 +137,16 @@ internal ConversationSession( _ = ConstStateWriter.Apply(State); Runner = new ConversationTurnRunner(this); - Stream = new ConversationTurnStream(this); - Writers = new ConversationTurnWriters(this); - Cuts = new ConversationCutTracker(this); - Ledger = new ConversationTranscriptLedger(this); - States = new ConversationSessionStateStore(this); - Lifetime = new ConversationSessionLifetime(this); - Busy = new ConversationBusyMark(this); - ToolPairs = new FinishedToolPairs(this); - ToolRuns = new ConversationToolRuns(ToolPairs.Keep); + Actions = new ConversationActions(this); } /// @@ -371,6 +354,12 @@ public bool EndConversation(ConversationEndReason reason) return Lifetime.EndConversation(reason); } + /// + public ConversationActionResult Request(ConversationAction action) + { + return Actions.Request(action); + } + /// /// 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. diff --git a/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs b/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs new file mode 100644 index 00000000..7d0ffd05 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs @@ -0,0 +1,11 @@ +using AgentCore.Application.Conversation.Actions; + +namespace AgentCore.Application.Runtime.Session +{ + /// Implemented by the transport, because only the transport can perform an action such as a transfer. + internal interface IConversationChannel + { + /// Must not block: a tool call is waiting on it, and the action itself runs after the answer was delivered. + ConversationActionResult Request(ConversationAction action); + } +} diff --git a/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs b/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs new file mode 100644 index 00000000..2417b3e3 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs @@ -0,0 +1,21 @@ +using AgentCore.Application.Conversation.Actions; + +namespace AgentCore.Application.Runtime.Session +{ + /// Keeps ToolCallScope.Conversation non-null, so a tool never checks for null; outside a turn there is nothing to act on. + internal sealed class UnattachedConversationControl : IConversationControl + { + internal static readonly UnattachedConversationControl Instance = new(); + + private UnattachedConversationControl() + { + } + + /// + public ConversationActionResult Request(ConversationAction action) + { + ArgumentNullException.ThrowIfNull(action); + return ConversationActionResult.NotSupported; + } + } +} diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs index af5d6133..5faf88cf 100644 --- a/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Tools; +using AgentCore.Application.Runtime.Session; using AgentCore.Application.Runtime.Turn; namespace AgentCore.Application.Runtime.ToolCalls @@ -20,7 +21,11 @@ internal static ToolCallScope From(TurnInvocation invocation) { ArgumentNullException.ThrowIfNull(invocation); - return new ToolCallScope(invocation.ConversationId, invocation.TurnIndex, invocation.Stage, invocation.Workspace) { Items = invocation.Items }; + return new ToolCallScope(invocation.ConversationId, invocation.TurnIndex, invocation.Stage, invocation.Workspace) + { + Items = invocation.Items, + Conversation = invocation.Control ?? UnattachedConversationControl.Instance, + }; } } } diff --git a/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs index efbc8836..92f5d3a3 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using AgentCore.Application.Conversation.Actions; using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.State; @@ -77,6 +78,9 @@ internal sealed record TurnInvocation /// Gets the session's hook raiser, or outside a conversation turn. public SessionHooks? Hooks { get; init; } + /// Carried here so that a tool's scope and a background child's tools reach the same session door. + public IConversationControl? Control { get; init; } + /// /// Gets the turn's shared state, which gates expose as Items and tools as , /// or outside a conversation turn. Nested runs share their parent's. diff --git a/src/AgentCore.Application/Tools/ToolCallScope.cs b/src/AgentCore.Application/Tools/ToolCallScope.cs index 96e8a837..8687db18 100644 --- a/src/AgentCore.Application/Tools/ToolCallScope.cs +++ b/src/AgentCore.Application/Tools/ToolCallScope.cs @@ -1,3 +1,6 @@ +using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Runtime.Session; + namespace AgentCore.Application.Tools { /// The conversation a bound tool runs in. A binding declares a parameter of this type to receive it; the model never sees that parameter. @@ -12,5 +15,11 @@ public sealed record ToolCallScope(string ConversationId, int TurnIndex, string /// turn. Durable data belongs in ConversationRecord.Custom. /// public IDictionary? Items { get; init; } + + /// + /// Gets the conversation's action door. It sits on the scope because the binder already hands every tool the + /// scope, so a tool needs no new parameter. Outside a turn it refuses every action: there is no conversation. + /// + public IConversationControl Conversation { get; init; } = UnattachedConversationControl.Instance; } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs index 5ce2a7aa..ed0cafe8 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs @@ -5,8 +5,10 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive { - /// The providers.conversation.live block, read and checked at boot. - /// AgentCore adds no prompt text of its own: every word GPT-Live is told to say comes from this block. + /// + /// The providers.conversation.live block, read and checked at boot so a typo fails the start and not the first + /// caller. Every word GPT-Live hears comes from here, because AgentCore holds no prompt text of its own. + /// /// GPT-Live's whole prompt, sent as written. /// The voice GPT-Live speaks with. /// The GPT-Live model. @@ -36,6 +38,7 @@ internal static OpenAiLiveSettings From(ConversationProviderConfiguration config string? voice = null; string? model = null; string? greeting = null; + foreach (JsonProperty property in configuration.Live.EnumerateObject()) { switch (property.Name) diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs b/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs new file mode 100644 index 00000000..48369edd --- /dev/null +++ b/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs @@ -0,0 +1,124 @@ +using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Runtime.Session; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Hooks; +using AgentCore.Application.Tools; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Application.Tests.Conversation +{ + public sealed class ConversationActionsTests + { + private static readonly Uri Staff = new("tel:+15550002222"); + + [Fact] + public async Task AnEndActionEndsTheConversationWithItsReason() + { + RecordingHook hook = new(); + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]); + + Assert.Equal(ConversationActionResult.Scheduled, session.Request(new EndConversationAction(ConversationEndReason.TransferredToHuman))); + await session.FlushNoticesAsync(); + + Assert.Equal(ConversationEndReason.TransferredToHuman, Assert.Single(hook.Of()).Reason); + } + + [Fact] + public void AnEndingConversationTakesNoActionAndNeverAsksTheChannel() + { + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); + RecordingChannel channel = new(ConversationActionResult.Scheduled); + session.Actions.Attach(channel); + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + + Assert.Equal(ConversationActionResult.Ending, session.Request(new EndConversationAction(ConversationEndReason.AgentCompleted))); + Assert.Equal(ConversationActionResult.Ending, session.Request(new TransferAction(Staff))); + Assert.Empty(channel.Seen); + } + + [Fact] + public void ATransferWithNoChannelIsNotSupportedAndLeavesTheConversationOpen() + { + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); + + Assert.Equal(ConversationActionResult.NotSupported, session.Request(new TransferAction(Staff))); + Assert.False(session.IsComplete); + } + + [Fact] + public void TheAttachedChannelTakesATransferAndADetachedOneNoLonger() + { + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); + RecordingChannel channel = new(ConversationActionResult.Scheduled); + TransferAction transfer = new(Staff); + + session.Actions.Attach(channel); + Assert.Equal(ConversationActionResult.Scheduled, session.Request(transfer)); + Assert.Same(transfer, Assert.Single(channel.Seen)); + + session.Actions.Detach(channel); + Assert.Equal(ConversationActionResult.NotSupported, session.Request(transfer)); + } + + // A call that a newer call took over detaches late: it must not drop the newer call's channel. + [Fact] + public void DetachingAnOlderChannelKeepsTheNewerOne() + { + using ScriptedChatClient reply = new("hello"); + ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); + RecordingChannel older = new(ConversationActionResult.Scheduled); + RecordingChannel newer = new(ConversationActionResult.Scheduled); + session.Actions.Attach(older); + session.Actions.Attach(newer); + + session.Actions.Detach(older); + + Assert.Equal(ConversationActionResult.Scheduled, session.Request(new TransferAction(Staff))); + Assert.Single(newer.Seen); + Assert.Empty(older.Seen); + } + + [Fact] + public void AScopeBuiltOutsideATurnCanDoNothing() + { + ToolCallScope scope = new("c-1", 0, string.Empty); + + Assert.Equal(ConversationActionResult.NotSupported, scope.Conversation.Request(new EndConversationAction(ConversationEndReason.AgentCompleted))); + } + + [Theory] + [InlineData("tel:+15550002222")] + [InlineData("sip:agent@example.com")] + [InlineData("sips:agent@example.com")] + public void ATransferTakesTelSipAndSipsTargets(string target) + { + Assert.Equal(new Uri(target), new TransferAction(new Uri(target)).Target); + } + + [Theory] + [InlineData("https://example.com/staff")] + [InlineData("mailto:staff@example.com")] + public void ATransferRefusesAnyOtherTarget(string target) + { + _ = Assert.Throws(() => new TransferAction(new Uri(target))); + } + + private sealed class RecordingChannel(ConversationActionResult answer) : IConversationChannel + { + public List Seen { get; } = []; + + public ConversationActionResult Request(ConversationAction action) + { + Seen.Add(action); + return answer; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs index 3a5786f8..98725f06 100644 --- a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Conversation.Actions; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; @@ -7,6 +8,7 @@ using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; using AgentCore.Application.Tools.Registry; +using AgentCore.Domain.Audit; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Runtime.Session; @@ -41,22 +43,7 @@ public async Task ABoundTool_ReceivesTheRunningConversationsScope() List captured = []; ToolBindingRegistry bindings = new(); _ = bindings.Register("RequestHuman", (string reason, ToolCallScope scope) => captured.Add(scope)); - - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(Yaml); - FakeChatClientFactory chatClients = new(new PerTurnToolCallingChatClient("connecting you now.")); - CompiledAgent compiled = ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(chatClients) - { - Tools = await ToolRegistryBuilder.BuildAsync( - [new BindingToolSource(bindings)], - new ToolSourceContext(document), - TestContext.Current.CancellationToken), - })["main"]; - - ConversationSessionFactory factory = new( - compiled, new GuardEvaluator(compiled.Configuration.Guards)); - ConversationSession session = factory.Create("conversation-scope-1"); + ConversationSession session = await CreateAsync(bindings, "conversation-scope-1"); _ = await session.RunTurnAsync("I need a person", TestContext.Current.CancellationToken); @@ -73,6 +60,39 @@ [new BindingToolSource(bindings)], Assert.Equal(1, second.TurnIndex); } + [Fact] + public async Task ABoundToolAsksItsOwnConversationForAnActionThroughTheScope() + { + List answers = []; + ToolBindingRegistry bindings = new(); + _ = bindings.Register( + "RequestHuman", + (string reason, ToolCallScope scope) => answers.Add(scope.Conversation.Request(new EndConversationAction(ConversationEndReason.TransferredToHuman)))); + ConversationSession session = await CreateAsync(bindings, "conversation-scope-2"); + + _ = await session.RunTurnAsync("I need a person", TestContext.Current.CancellationToken); + + Assert.Equal(ConversationActionResult.Scheduled, Assert.Single(answers)); + Assert.True(session.IsComplete); + } + + private static async Task CreateAsync(ToolBindingRegistry bindings, string conversationId) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(Yaml); + FakeChatClientFactory chatClients = new(new PerTurnToolCallingChatClient("connecting you now.")); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) + { + Tools = await ToolRegistryBuilder.BuildAsync( + [new BindingToolSource(bindings)], + new ToolSourceContext(document), + TestContext.Current.CancellationToken), + })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(conversationId); + } + /// /// Calls the one offered tool once per turn, rather than once per conversation: it looks only at what /// came after the caller's latest message, so a tool call answered in an earlier turn does not From 10cb49de5f8a37359069356c087bb3efe490851c Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 4 Oct 2026 21:05:00 -0600 Subject: [PATCH 4/6] feat: Implement call transfer functionality in OpenAiLive --- config/example.yaml | 4 + .../Schema/agentcore-v1.schema.json | 2 +- .../Runtime/Session/ConversationActions.cs | 6 - src/AgentCore.AspNetCore/Calls/CallBrief.cs | 38 +++ src/AgentCore.AspNetCore/Calls/CallChannel.cs | 29 ++ src/AgentCore.AspNetCore/Calls/PhoneCall.cs | 37 +-- .../Vendors/OpenAiLive/Call/LiveAnswers.cs | 115 ++++++++ .../Vendors/OpenAiLive/Call/LiveHearing.cs | 40 +++ .../Vendors/OpenAiLive/Call/LiveTransfer.cs | 53 ++++ .../Vendors/OpenAiLive/Call/OpenAiLiveCall.cs | 275 ++++++++++-------- .../Vendors/OpenAiLive/OpenAiLiveLog.cs | 9 + .../Vendors/OpenAiLive/OpenAiLiveSettings.cs | 27 +- .../OpenAiLive/Webhook/OpenAiLiveWebhook.cs | 9 +- .../OpenAiLive/Wire/OpenAiLiveControl.cs | 6 +- .../Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs | 5 + .../Conversation/ConversationActionsTests.cs | 11 +- .../Calls/PhoneCallChannelTests.cs | 36 +++ .../Fakes/RunningLiveCall.cs | 21 +- .../OpenAiLive/OpenAiLiveSettingsTests.cs | 11 +- .../OpenAiLive/OpenAiLiveTransferTests.cs | 163 +++++++++++ .../Vendors/OpenAiLive/OpenAiLiveWireTests.cs | 8 + 21 files changed, 731 insertions(+), 174 deletions(-) create mode 100644 src/AgentCore.AspNetCore/Calls/CallBrief.cs create mode 100644 src/AgentCore.AspNetCore/Calls/CallChannel.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveAnswers.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs diff --git a/config/example.yaml b/config/example.yaml index ea253d7b..357ae211 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -381,6 +381,10 @@ providers: # # optional: without it GPT-Live waits for the caller to speak # voice: marin # optional, default marin # model: gpt-live-1 # optional, default gpt-live-1 + # transferSeconds: 45 # optional, default 45. OpenAI never says if a transfer worked: the + # # SIP peer hangs up the AI leg once the line took the call. A call still + # # up after this long counts as a failed transfer and is hung up. Set it + # # longer than the peer rings the target. speech: stt: { kind: telnyx-relay } # bundled with the relay, so it matches conversation tts: { kind: telnyx-relay } # bundled with the relay, so it matches conversation diff --git a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json index 84ea9bbf..550425f0 100644 --- a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json +++ b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json @@ -1363,7 +1363,7 @@ "description": "The largest inbound frame the socket accepts. The ceiling is int.MaxValue, which is the largest buffer length the socket can be given." }, "answerSeconds": { "type": "integer", "minimum": 1, "maximum": 60, "description": "How long the call gate gives each hook to accept or reject an offered call, in seconds. Omit it for 5." }, - "live": { "type": "object", "description": "The openai-live vendor's own settings: instructions, greeting, voice, model. Only that adapter reads and checks them." }, + "live": { "type": "object", "description": "The openai-live vendor's own settings: instructions, greeting, voice, model, transferSeconds. Only that adapter reads and checks them." }, "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.", diff --git a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs b/src/AgentCore.Application/Runtime/Session/ConversationActions.cs index a14f989b..0aefe8c7 100644 --- a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationActions.cs @@ -17,12 +17,6 @@ internal void Attach(IConversationChannel channel) Volatile.Write(ref _channel, channel); } - /// Compare-and-clear, because an older call can detach after a newer call attached, and must not drop it. - internal void Detach(IConversationChannel channel) - { - _ = Interlocked.CompareExchange(ref _channel, null, channel); - } - /// public ConversationActionResult Request(ConversationAction action) { diff --git a/src/AgentCore.AspNetCore/Calls/CallBrief.cs b/src/AgentCore.AspNetCore/Calls/CallBrief.cs new file mode 100644 index 00000000..303fc8e6 --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallBrief.cs @@ -0,0 +1,38 @@ +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Calls +{ + /// + /// The brief the call gate accepted a call with. It is filed under the conversation id, not the call, so a call must + /// clear what an earlier call filed and must not clear what a newer call filed. + /// + internal sealed class CallBrief(PhoneCallHost host, string conversationId, string? text) + { + internal string? Text => text; + + /// Files the brief, or clears an earlier call's when this call brought none. + internal void File() + { + // A vendor that speaks for itself files an entry even with no brief: the engine must still get the front-voice note. + if (string.IsNullOrEmpty(text) && !host.FrontVoice) + { + Clear(); + } + else + { + host.Briefs?.Set(conversationId, string.IsNullOrEmpty(text) ? null : text, host.FrontVoice); + } + } + + internal void Clear() => host.Briefs?.Clear(conversationId); + + // A newer session that another call holds carries that call's brief, so only then is it left in place. + internal void Forget(ConversationSession? holder, ConversationSession own) + { + if (PhoneCall.Owns(holder, own) || !holder!.Lifetime.Ending.HasCall) + { + Clear(); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Calls/CallChannel.cs b/src/AgentCore.AspNetCore/Calls/CallChannel.cs new file mode 100644 index 00000000..6e3962ee --- /dev/null +++ b/src/AgentCore.AspNetCore/Calls/CallChannel.cs @@ -0,0 +1,29 @@ +using AgentCore.Application.Runtime.Session; + +namespace AgentCore.AspNetCore.Calls +{ + /// + /// The vendor's actions, such as a transfer, on the call's conversation. It follows the call to a session reopened + /// after an idle unload, because the tools of the next ask run on that session. + /// + internal sealed class CallChannel + { + private IConversationChannel? _channel; + + internal void Use(IConversationChannel channel, ConversationSession session) + { + ArgumentNullException.ThrowIfNull(channel); + ArgumentNullException.ThrowIfNull(session); + Volatile.Write(ref _channel, channel); + session.Actions.Attach(channel); + } + + internal void Follow(ConversationSession reopened) + { + if (Volatile.Read(ref _channel) is { } channel) + { + reopened.Actions.Attach(channel); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs index c7c3672c..8b411ada 100644 --- a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs +++ b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs @@ -23,6 +23,8 @@ internal sealed class PhoneCall private readonly CallEndNotice _end; + private readonly CallBrief _brief; + private ConversationSession _session; private DateTimeOffset? _keptStart; @@ -46,7 +48,7 @@ private PhoneCall(PhoneCallHost host, CallOffer offer, string conversationId, st _end = new CallEndNotice(offer.CallId, host.Time, host.Logger); _session = session; ConversationId = conversationId; - Brief = brief; + _brief = new CallBrief(host, conversationId, brief); Asks = new CallAsks(this); } @@ -59,7 +61,7 @@ private PhoneCall(PhoneCallHost host, CallOffer offer, string conversationId, st internal string ConversationId { get; } /// Gets the brief the hook accepted the call with, for the vendor's own voice; for none. - internal string? Brief { get; } + internal string? Brief => _brief.Text; internal ConversationSession Session => Volatile.Read(ref _session); @@ -84,6 +86,8 @@ private PhoneCall(PhoneCallHost host, CallOffer offer, string conversationId, st /// Gets the engine turns the vendor asks for, one per delegation. internal CallAsks Asks { get; } + internal CallChannel Channel { get; } = new(); + /// /// Runs the call gate, then opens the conversation the hook named. A conversation that /// a call with another id holds refuses the call as busy: one live call per conversation. A live call @@ -175,21 +179,13 @@ internal async ValueTask StartAsync(CancellationToken cancellationToken) Volatile.Write(ref _startedAt, new StrongBox(startedAt)); - // The brief is filed under the conversation id, so a call with none must not inherit an earlier call's. - if (string.IsNullOrEmpty(Brief) && !Host.FrontVoice) - { - Host.Briefs?.Clear(ConversationId); - } - else - { - Host.Briefs?.Set(ConversationId, string.IsNullOrEmpty(Brief) ? null : Brief, Host.FrontVoice); - } + _brief.File(); // An end that landed after the check above may have forgotten the brief before it was set here, and it // raises its own end: the call does not start. if (HasEnded) { - Host.Briefs?.Clear(ConversationId); + _brief.Clear(); return; } @@ -290,7 +286,7 @@ internal async ValueTask EndAsync(ConversationEndReason reason, string? cause) } _end.Left(holder, own, CallEndReason.Ended, cause); - ForgetBrief(holder, own); + _brief.Forget(holder, own); } /// @@ -307,7 +303,7 @@ internal async ValueTask CloseAsync() } _end.Left(holder, own, CallEndReason.Closed, cause: null); - ForgetBrief(holder, own); + _brief.Forget(holder, own); if (Owns(holder, own)) { await Host.Sessions.CloseAsync(Entry, ConversationId, CancellationToken.None).ConfigureAwait(false); @@ -343,10 +339,11 @@ private bool TryAdopt(ConversationSession reopened, DateTimeOffset startedAt) } Volatile.Write(ref _session, reopened); + Channel.Follow(reopened); return true; } - private static bool Owns(ConversationSession? holder, ConversationSession own) + internal static bool Owns(ConversationSession? holder, ConversationSession own) { return holder is null || ReferenceEquals(holder, own); } @@ -365,16 +362,6 @@ private void OnReplaced() _replaced?.Invoke(this); } - // The brief is filed under the conversation id. A newer session that another call holds carries that call's - // brief, so only then is it left in place. - private void ForgetBrief(ConversationSession? holder, ConversationSession own) - { - if (Owns(holder, own) || !holder!.Lifetime.Ending.HasCall) - { - Host.Briefs?.Clear(ConversationId); - } - } - // The id may have moved on: this call's session unloaded and a later caller reopened it. Ending or closing that // newer session would answer this call's hang-up with someone else's call. An unreadable store falls back to // this call's own session. The lookup takes no token, so a cancellation here is the store's own timeout: a fault diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveAnswers.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveAnswers.cs new file mode 100644 index 00000000..d6d85db7 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveAnswers.cs @@ -0,0 +1,115 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// Answers GPT-Live's delegations through the call core. The answers run off the read loop, and the loop must wait + /// for every one of them before it closes the call, so they are kept here as one set. + /// + internal sealed class LiveAnswers( + PhoneCall call, + LiveCallEnd end, + Func send, + Func nextEventId, + Func leavesAfter, + ILogger logger) + { + internal const string NothingNew = "The caller said nothing new since the last answer."; + + private readonly List _answers = []; + + internal void Start(string delegationId, IReadOnlyList before, string words, CancellationToken cancellationToken) + { + lock (_answers) + { + _answers.Add(AnswerAsync(delegationId, before, words, cancellationToken)); + } + } + + internal Task WhenAnsweredAsync() + { + lock (_answers) + { + return Task.WhenAll(_answers); + } + } + + // AskAsync runs before this method's first await: asks take their place in arrival order. The call + // never ends from inside the ask (EndAsync would wait up to CallAsks.OlderAskWait on the ask itself), and never + // from this task: the loop ends it, after its ledger flush. + private async Task AnswerAsync(string delegationId, IReadOnlyList before, string words, CancellationToken cancellationToken) + { + try + { + if (words.Length == 0) + { + await send(OpenAiLiveEvents.Thinking(delegationId, NothingNew, nextEventId()), cancellationToken).ConfigureAwait(false); + if (leavesAfter()) + { + end.Now(ConversationEndReason.AgentCompleted, OpenAiLiveCall.AgentEndedCause); + } + + return; + } + + CallAnswer answer = await call.Asks.AskAsync( + delegationId, + words, + [.. before.Select(line => line.Speaker == Speaker.Caller ? new ChatMessage(ChatRole.User, line.Text) : FrontVoice.Line(line.Text))], + tool => send(OpenAiLiveEvents.Thinking(delegationId, $"The backend is running the tool {tool}. No answer yet.", nextEventId()), cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (answer.Kind == CallAnswerKind.Withdrawn) + { + return; + } + + // Read now and not when the delegation came: a tool of this very answer may have ended the conversation + // or asked for a transfer. + await SpeakAsync(delegationId, answer.Text, leavesAfter(), cancellationToken).ConfigureAwait(false); + } + catch (CallConversationLostException) + { + end.Now(ConversationEndReason.Faulted, OpenAiLiveCall.ConversationLostCause); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The host is stopping. + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + OpenAiLiveLog.AnswerFaulted(logger, call.CallId, fault); + } + } + + // A call that leaves after this answer leaves once GPT-Live acked the last piece and then went quiet for + // EndQuietWait, or AckWait after that piece's send with no ack: the caller hears the whole answer first. + private async Task SpeakAsync(string delegationId, string text, bool leaveAfter, CancellationToken cancellationToken) + { + IReadOnlyList pieces = CommentaryPieces.Split(text); + if (leaveAfter && pieces.Count == 0) + { + end.Now(ConversationEndReason.AgentCompleted, OpenAiLiveCall.AgentEndedCause); + return; + } + + for (int index = 0; index < pieces.Count; index++) + { + string eventId = nextEventId(); + if (leaveAfter && index == pieces.Count - 1) + { + end.AfterAck(eventId); + } + + await send(OpenAiLiveEvents.Commentary(delegationId, pieces[index], eventId), cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs new file mode 100644 index 00000000..82f5e7c5 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs @@ -0,0 +1,40 @@ +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// Turns GPT-Live's transcript deltas into closed lines and raises each through the call core. Only the read loop + /// calls it, so the ledger needs no lock. + /// + internal sealed class LiveHearing(PhoneCall call) + { + private readonly LiveTranscriptLedger _ledger = new(); + + internal ValueTask HearAsync(LiveEvent.Transcript delta) => RaiseAsync(_ledger.Add(delta)); + + internal ValueTask FlushAsync() => RaiseAsync(_ledger.Flush()); + + /// Raises the lines the delegation closed, and gives the ask its context and the caller's new words. + internal async ValueTask<(IReadOnlyList Before, string Words)> TakeForDelegationAsync() + { + (IReadOnlyList closed, IReadOnlyList before, string words) = _ledger.TakeForDelegation(); + await RaiseAsync(closed).ConfigureAwait(false); + return (before, words); + } + + // Line times are offsets from the call's start on GPT-Live's audio clock, so they are placed on the call's own start. + private async ValueTask RaiseAsync(IReadOnlyList lines) + { + foreach (LiveLine line in lines) + { + await call.HeardAsync( + line.Speaker, + line.Text, + call.StartedAt.AddMilliseconds(line.StartMs), + call.StartedAt.AddMilliseconds(line.EndMs), + call.Asks.AnsweredTurn).ConfigureAwait(false); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs new file mode 100644 index 00000000..11fc7a67 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs @@ -0,0 +1,53 @@ +using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Runtime.Session; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// GPT-Live's side of . A request only records the target: the call's read loop sends the + /// refer once the caller heard the answer, so the transfer never cuts a reply. + /// + internal sealed class LiveTransfer(Func> refer, TimeSpan wait, Func leaving, Action scheduled) : IConversationChannel + { + private Uri? _target; + + internal Uri? Target => Volatile.Read(ref _target); + + internal TimeSpan Wait => wait; + + public ConversationActionResult Request(ConversationAction action) + { + ArgumentNullException.ThrowIfNull(action); + if (action is not TransferAction transfer) + { + return ConversationActionResult.NotSupported; + } + + // A call that is leaving has no line left to hand over, and a second transfer would contradict the first, + // which may already be on the line. + if (leaving() || Interlocked.CompareExchange(ref _target, transfer.Target, null) is not null) + { + return ConversationActionResult.Ending; + } + + // A transfer asked outside a delegation has no answer of its own to leave after. + scheduled(); + return ConversationActionResult.Scheduled; + } + + /// A thrown refer counts as a refused one: either way the line never got the call. + internal async Task ReferAsync(Uri target, ILogger logger, string callId, CancellationToken cancellationToken) + { + try + { + return await refer(target, cancellationToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + OpenAiLiveLog.ReferFaulted(logger, callId, fault); + return false; + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index 6837f8db..7783b150 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -12,11 +12,9 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { /// /// One live GPT-Live call: reads the sideband, keeps the transcript ledger, and answers each delegation through the - /// call core. Talks to the engine only through . A greeting leaves GPT-Live - /// silent until the caller speaks. + /// call core. Talks to the engine only through . /// - internal sealed class OpenAiLiveCall( - PhoneCall call, ILiveSideband sideband, Func> hangUp, ILogger logger, string? greeting = null) : IDisposable + internal sealed class OpenAiLiveCall : IDisposable { internal const string ShutdownCause = "shutdown"; @@ -30,7 +28,9 @@ internal sealed class OpenAiLiveCall( internal const string ConversationLostCause = "conversation_lost"; - internal const string NothingNew = "The caller said nothing new since the last answer."; + internal const string TransferredCause = "transferred"; + + internal const string TransferFailedCause = "transfer_failed"; /// How long after its send the last answer piece may go unacked before an engine-ended call hangs up anyway. internal static readonly TimeSpan AckWait = TimeSpan.FromSeconds(5); @@ -41,14 +41,26 @@ internal sealed class OpenAiLiveCall( /// internal static readonly TimeSpan EndQuietWait = TimeSpan.FromSeconds(2); - private readonly LiveTranscriptLedger _ledger = new(); + private readonly PhoneCall _call; + + private readonly ILiveSideband _sideband; + + private readonly Func> _hangUp; + + private readonly ILogger _logger; + + private readonly string? _greeting; + + private readonly LiveHearing _hearing; private readonly SemaphoreSlim _sending = new(1, 1); - private readonly List _answers = []; + private readonly LiveAnswers _answers; + + private readonly LiveTransfer? _transfer; // Set off the loop (an answer, a timer) and acted on by the loop, which alone touches the ledger. - private readonly LiveCallEnd _end = new(call.Host.Time, call.CallId, logger); + private readonly LiveCallEnd _end; private readonly TaskCompletionSource _loopLeft = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -58,9 +70,35 @@ internal sealed class OpenAiLiveCall( private long _eventIds; - internal string CallId => call.CallId; + /// + /// A greeting leaves GPT-Live silent until the caller speaks. A + /// refer leaves the call unable to transfer, so its conversation answers a transfer as not supported. + /// + internal OpenAiLiveCall( + PhoneCall call, + ILiveSideband sideband, + Func> hangUp, + ILogger logger, + string? greeting = null, + Func>? refer = null, + TimeSpan? transferWait = null) + { + _call = call; + _sideband = sideband; + _hangUp = hangUp; + _logger = logger; + _greeting = greeting; + _end = new LiveCallEnd(call.Host.Time, call.CallId, logger); + _hearing = new LiveHearing(call); + _transfer = refer is null + ? null + : new LiveTransfer(refer, transferWait ?? OpenAiLiveSettings.DefaultTransferWait, () => _end.Finished || call.HasEnded, () => _ = TransferAfterAnswersAsync()); + _answers = new LiveAnswers(call, _end, SendAsync, NextEventId, () => call.EngineEnded || _transfer?.Target is not null, logger); + } + + internal string CallId => _call.CallId; - internal ILogger Logger => logger; + internal ILogger Logger => _logger; /// Disposes the send lock and the end timers. calls this once every answer is done. public void Dispose() @@ -74,29 +112,42 @@ internal async Task RunAsync(CancellationToken cancellationToken) Task? receiving = null; Volatile.Write(ref _looping, 1); _ = HangUpAfterAnyEngineEndAsync(); + if (_transfer is not null) + { + _call.Channel.Use(_transfer, _call.Session); + } + try { // GPT-Live knows no date of its own: on a real call it named the wrong weekday. - if (call.Session.Runner.ClockLine() is { } clock) + if (_call.Session.Runner.ClockLine() is { } clock) { await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false); } - if (greeting is not null) + if (_greeting is not null) { - await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), greeting), cancellationToken).ConfigureAwait(false); + await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), _greeting), cancellationToken).ConfigureAwait(false); } while (true) { - receiving ??= sideband.ReceiveAsync(cancellationToken).AsTask(); + receiving ??= _sideband.ReceiveAsync(cancellationToken).AsTask(); _ = await Task.WhenAny(receiving, _end.Requested).ConfigureAwait(false); if (_end.Requested.IsCompleted) { (ConversationEndReason reason, string cause) = await _end.Requested.ConfigureAwait(false); await HearReceivedAsync(receiving).ConfigureAwait(false); - await HearAsync(_ledger.Flush()).ConfigureAwait(false); - OpenAiLiveLog.HungUpBecause(logger, CallId, cause); + await _hearing.FlushAsync().ConfigureAwait(false); + + // The quiet wait after the last answer ends in the transfer when one waits, in place of the hang-up. + if (cause == AgentEndedCause && _transfer is { Target: { } target }) + { + receiving = await TransferAsync(_transfer, target, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false); + return; + } + + OpenAiLiveLog.HungUpBecause(_logger, CallId, cause); await HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); return; } @@ -114,7 +165,7 @@ internal async Task RunAsync(CancellationToken cancellationToken) } } - await HearAsync(_ledger.Flush()).ConfigureAwait(false); + await _hearing.FlushAsync().ConfigureAwait(false); await FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -123,16 +174,16 @@ internal async Task RunAsync(CancellationToken cancellationToken) } catch (Exception fault) when (fault is not OperationCanceledException) { - OpenAiLiveLog.SidebandFaulted(logger, CallId, fault); - await HearAsync(_ledger.Flush()).ConfigureAwait(false); + OpenAiLiveLog.SidebandFaulted(_logger, CallId, fault); + await _hearing.FlushAsync().ConfigureAwait(false); await FinishAsync(ConversationEndReason.Faulted, SidebandFailedCause).ConfigureAwait(false); } finally { _ = _loopLeft.TrySetResult(); - await WhenAnsweredAsync().ConfigureAwait(false); + await _answers.WhenAnsweredAsync().ConfigureAwait(false); Dispose(); - await sideband.DisposeAsync().ConfigureAwait(false); + await _sideband.DisposeAsync().ConfigureAwait(false); // A receive left behind by an end ends with the socket; nothing reads its result. _ = receiving?.ContinueWith(static left => left.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); @@ -172,15 +223,16 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken _end.AgentSpoke(); } - await HearAsync(_ledger.Add(delta)).ConfigureAwait(false); + await _hearing.HearAsync(delta).ConfigureAwait(false); return false; case LiveEvent.Delegation delegation: - (IReadOnlyList closed, IReadOnlyList before, string words) = _ledger.TakeForDelegation(); - await HearAsync(closed).ConfigureAwait(false); - lock (_answers) + (IReadOnlyList before, string words) = await _hearing.TakeForDelegationAsync().ConfigureAwait(false); + + // Once a transfer waits, no new turn starts: the caller is leaving for the line. + if (_transfer?.Target is null) { - _answers.Add(AnswerAsync(delegation.Id, before, words, cancellationToken)); + _answers.Start(delegation.Id, before, words, cancellationToken); } return false; @@ -190,12 +242,12 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken return false; case LiveEvent.Closed end: - await HearAsync(_ledger.Flush()).ConfigureAwait(false); + await _hearing.FlushAsync().ConfigureAwait(false); await FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); return true; case LiveEvent.Failed failed: - OpenAiLiveLog.LiveError(logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty); + OpenAiLiveLog.LiveError(_logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty); return false; default: @@ -203,92 +255,29 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken } } - // AskAsync runs before this method's first await: asks take their place in arrival order. The call - // never ends from inside the ask (EndAsync would wait up to CallAsks.OlderAskWait on the ask itself), and never - // from this task: the loop ends it, after its ledger flush. - private async Task AnswerAsync(string delegationId, IReadOnlyList before, string words, CancellationToken cancellationToken) - { - try - { - if (words.Length == 0) - { - await SendAsync(OpenAiLiveEvents.Thinking(delegationId, NothingNew, NextEventId()), cancellationToken).ConfigureAwait(false); - if (call.EngineEnded) - { - EndNow(ConversationEndReason.AgentCompleted, AgentEndedCause); - } - - return; - } - - CallAnswer answer = await call.Asks.AskAsync( - delegationId, - words, - [.. before.Select(line => line.Speaker == Speaker.Caller ? new ChatMessage(ChatRole.User, line.Text) : FrontVoice.Line(line.Text))], - tool => SendAsync(OpenAiLiveEvents.Thinking(delegationId, $"The backend is running the tool {tool}. No answer yet.", NextEventId()), cancellationToken), - cancellationToken).ConfigureAwait(false); - - if (answer.Kind == CallAnswerKind.Withdrawn) - { - return; - } - - await SpeakAsync(delegationId, answer.Text, endAfter: call.EngineEnded, cancellationToken).ConfigureAwait(false); - } - catch (CallConversationLostException) - { - EndNow(ConversationEndReason.Faulted, ConversationLostCause); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // The host is stopping. - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - OpenAiLiveLog.AnswerFaulted(logger, CallId, fault); - } - } - - // Once the engine ended the conversation, the call hangs up after GPT-Live acked the last piece and - // then went quiet for EndQuietWait, or AckWait after that piece's send with no ack. - private async Task SpeakAsync(string delegationId, string text, bool endAfter, CancellationToken cancellationToken) - { - IReadOnlyList pieces = CommentaryPieces.Split(text); - if (endAfter && pieces.Count == 0) - { - EndNow(ConversationEndReason.AgentCompleted, AgentEndedCause); - return; - } - - for (int index = 0; index < pieces.Count; index++) - { - string eventId = NextEventId(); - if (endAfter && index == pieces.Count - 1) - { - _end.AfterAck(eventId); - } - - await SendAsync(OpenAiLiveEvents.Commentary(delegationId, pieces[index], eventId), cancellationToken).ConfigureAwait(false); - } - } - // An end from outside every delegation (an operator's, the host's) has no answer of its own to hang up after. // Once the delegations in flight are answered, a call none of them set to end hangs up when GPT-Live's speech // has been quiet for EndQuietWait. The session is read once: an unload during the call is not followed. private async Task HangUpAfterAnyEngineEndAsync() { - await call.Session.Lifetime.Ending.WhenRequested.ConfigureAwait(false); - await WhenAnsweredAsync().ConfigureAwait(false); + await _call.Session.Lifetime.Ending.WhenRequested.ConfigureAwait(false); + await _answers.WhenAnsweredAsync().ConfigureAwait(false); _end.WhenQuiet(); } - private void EndNow(ConversationEndReason reason, string cause) => _end.Now(reason, cause); + // A transfer asked inside a delegation is set to leave after that answer; one asked from outside has no answer + // of its own, so it leaves once GPT-Live went quiet, as an engine end from outside does. + private async Task TransferAfterAnswersAsync() + { + await _answers.WhenAnsweredAsync().ConfigureAwait(false); + _end.WhenQuiet(); + } // A message that arrived together with the end is still part of the call: its words are heard. A delegation in // it is not answered, and the ledger flush after this closes the line it would have closed. private ValueTask HearReceivedAsync(Task? receiving) => receiving is { IsCompletedSuccessfully: true, Result: { } json } && LiveEventReader.Read(json) is LiveEvent.Transcript delta - ? HearAsync(_ledger.Add(delta)) + ? _hearing.HearAsync(delta) : ValueTask.CompletedTask; private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancellationToken) @@ -299,7 +288,7 @@ private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancel // Nothing goes out once the call ended: the socket may already be gone. if (!_end.Finished) { - await sideband.SendAsync(liveEvent.ToJsonString(), cancellationToken).ConfigureAwait(false); + await _sideband.SendAsync(liveEvent.ToJsonString(), cancellationToken).ConfigureAwait(false); } } finally @@ -308,16 +297,59 @@ private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancel } } - private async ValueTask HearAsync(IReadOnlyList lines) + // OpenAI never reports how a REFER went (probe T1, docs/probes/live-transfer-t1): the REST answer is 200 even + // when the peer declines, and no event follows. The peer hangs the AI leg up once the line took the call, so a + // close inside the wait is the transfer, and a call still up after it is a failed one. The caller's words are + // still heard meanwhile, but start no turn. Returns the receive still open, for the loop's cleanup. + private async Task?> TransferAsync(LiveTransfer transfer, Uri target, Task? receiving, CancellationToken cancellationToken) { - foreach (LiveLine line in lines) + OpenAiLiveLog.Transferring(_logger, CallId); + if (!await transfer.ReferAsync(target, _logger, CallId, cancellationToken).ConfigureAwait(false)) { - await call.HeardAsync( - line.Speaker, - line.Text, - call.StartedAt.AddMilliseconds(line.StartMs), - call.StartedAt.AddMilliseconds(line.EndMs), - call.Asks.AnsweredTurn).ConfigureAwait(false); + OpenAiLiveLog.TransferFailed(_logger, CallId, "the refer was refused"); + await HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false); + return receiving; + } + + using CancellationTokenSource waitStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task wait = Task.Delay(transfer.Wait, _call.Host.Time, waitStop.Token); + try + { + while (true) + { + receiving ??= _sideband.ReceiveAsync(cancellationToken).AsTask(); + if (await Task.WhenAny(receiving, wait).ConfigureAwait(false) == wait) + { + await _hearing.FlushAsync().ConfigureAwait(false); + + OpenAiLiveLog.TransferFailed(_logger, CallId, "the call was still up when the transfer wait ran out"); + + await HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false); + + return receiving; + } + + string? json = await receiving.ConfigureAwait(false); + receiving = null; + LiveEvent? liveEvent = json is null ? null : LiveEventReader.Read(json); + + if (liveEvent is LiveEvent.Transcript delta) + { + await _hearing.HearAsync(delta).ConfigureAwait(false); + } + else if (json is null || liveEvent is LiveEvent.Closed) + { + await _hearing.FlushAsync().ConfigureAwait(false); + + await FinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause).ConfigureAwait(false); + + return null; + } + } + } + finally + { + await waitStop.CancelAsync().ConfigureAwait(false); } } @@ -328,17 +360,17 @@ private async Task HangUpAndFinishAsync(ConversationEndReason reason, string cau bool hungUp; try { - hungUp = await hangUp(cancellationToken).ConfigureAwait(false); + hungUp = await _hangUp(cancellationToken).ConfigureAwait(false); } catch (Exception fault) when (fault is not OutOfMemoryException) { - OpenAiLiveLog.HangupFaulted(logger, CallId, fault); + OpenAiLiveLog.HangupFaulted(_logger, CallId, fault); hungUp = true; } if (!hungUp) { - OpenAiLiveLog.HangupFailed(logger, CallId); + OpenAiLiveLog.HangupFailed(_logger, CallId); } } @@ -352,24 +384,17 @@ private async Task FinishAsync(ConversationEndReason reason, string cause) return; } - await call.EndAsync(reason, cause).ConfigureAwait(false); - await WhenAnsweredAsync().ConfigureAwait(false); + await _call.EndAsync(reason, cause).ConfigureAwait(false); + await _answers.WhenAnsweredAsync().ConfigureAwait(false); + try { - await call.CloseAsync().ConfigureAwait(false); + await _call.CloseAsync().ConfigureAwait(false); } // The close takes no token, so a cancellation here is the store's own timeout. catch (Exception fault) when (fault is not OutOfMemoryException) { - OpenAiLiveLog.CloseFaulted(logger, CallId, fault); - } - } - - private Task WhenAnsweredAsync() - { - lock (_answers) - { - return Task.WhenAll(_answers); + OpenAiLiveLog.CloseFaulted(_logger, CallId, fault); } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs index 74fb2378..a7f15b30 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs @@ -44,5 +44,14 @@ internal static partial class OpenAiLiveLog [LoggerMessage(EventId = 322, Level = LogLevel.Error, Message = "running the call {CallId} failed.")] internal static partial void RunFaulted(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 324, Level = LogLevel.Information, Message = "the call {CallId} is being transferred.")] + internal static partial void Transferring(ILogger logger, string callId); + + [LoggerMessage(EventId = 325, Level = LogLevel.Warning, Message = "transferring the call {CallId} failed: {Why}. The call is hung up.")] + internal static partial void TransferFailed(ILogger logger, string callId, string why); + + [LoggerMessage(EventId = 326, Level = LogLevel.Error, Message = "sending the refer of the call {CallId} threw.")] + internal static partial void ReferFaulted(ILogger logger, string callId, Exception fault); } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs index ed0cafe8..9461f336 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs @@ -16,8 +16,18 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive /// The instruction GPT-Live gets once the call is up, such as "Greet the caller as your instructions say", or /// : GPT-Live then stays silent until the caller speaks. /// - internal sealed record OpenAiLiveSettings(string Instructions, string Voice, string Model, string? Greeting = null) + /// + /// How long a transfer may take before it counts as failed. OpenAI never reports the outcome, so only the peer's + /// hang-up of the AI leg says it worked; this must be longer than the peer rings the target. + /// + internal sealed record OpenAiLiveSettings(string Instructions, string Voice, string Model, string? Greeting = null, int TransferSeconds = OpenAiLiveSettings.DefaultTransferSeconds) { + internal const int DefaultTransferSeconds = 45; + + internal static readonly TimeSpan DefaultTransferWait = TimeSpan.FromSeconds(DefaultTransferSeconds); + + internal TimeSpan TransferWait => TimeSpan.FromSeconds(TransferSeconds); + internal const string DefaultVoice = "marin"; internal const string DefaultModel = "gpt-live-1"; @@ -38,6 +48,7 @@ internal static OpenAiLiveSettings From(ConversationProviderConfiguration config string? voice = null; string? model = null; string? greeting = null; + int? transferSeconds = null; foreach (JsonProperty property in configuration.Live.EnumerateObject()) { @@ -55,17 +66,20 @@ internal static OpenAiLiveSettings From(ConversationProviderConfiguration config case "greeting": greeting = Text(property); break; + case "transferSeconds": + transferSeconds = Seconds(property); + break; default: throw Fail( "/" + property.Name, - $"providers.conversation.live.{property.Name} is not a setting of the openai-live vendor. It reads instructions, greeting, voice, and model. " + $"providers.conversation.live.{property.Name} is not a setting of the openai-live vendor. It reads instructions, greeting, voice, model, and transferSeconds. " + $"The OpenAI key and the webhook secret come from the secret resolver as '{KnownSecrets.OpenAiApiKeyName}' and '{KnownSecrets.OpenAiWebhookSecretName}'."); } } return string.IsNullOrWhiteSpace(instructions) ? throw Fail("/instructions", "providers.conversation.live.instructions is missing or empty. GPT-Live needs its own prompt.") - : new OpenAiLiveSettings(instructions, voice ?? DefaultVoice, model ?? DefaultModel, greeting); + : new OpenAiLiveSettings(instructions, voice ?? DefaultVoice, model ?? DefaultModel, greeting, transferSeconds ?? DefaultTransferSeconds); } private static string Text(JsonProperty property) @@ -75,6 +89,13 @@ private static string Text(JsonProperty property) : throw Fail("/" + property.Name, $"providers.conversation.live.{property.Name} must be a non-empty string."); } + private static int Seconds(JsonProperty property) + { + return property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out int seconds) && seconds > 0 + ? seconds + : throw Fail("/" + property.Name, $"providers.conversation.live.{property.Name} must be a whole number of seconds above 0."); + } + private static ConfigurationLoadException Fail(string suffix, string message) { return new(new ConfigurationError diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs index 6d9d9127..5b0fa580 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -119,7 +119,14 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming return false; } - calls.Run(new OpenAiLiveCall(call, sideband, _ => control.HangupQuietlyAsync(incoming.CallId), logger, settings.Greeting)); + calls.Run(new OpenAiLiveCall( + call, + sideband, + _ => control.HangupQuietlyAsync(incoming.CallId), + logger, + settings.Greeting, + (target, token) => control.ReferAsync(incoming.CallId, target, token), + settings.TransferWait)); return true; } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs index 71ae13a6..5b13788b 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs @@ -6,7 +6,7 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire { - /// OpenAI's call-control requests for a SIP call: accept, reject, hang up. URLs and bodies come from . + /// OpenAI's call-control requests for a SIP call: accept, reject, hang up, refer. URLs and bodies come from . internal sealed class OpenAiLiveControl(HttpClient http, Task credentials) { /// The deadline of a best-effort request that must not wait on the host's token: a hang-up at shutdown, a reject after a failed accept. @@ -37,6 +37,10 @@ internal Task RejectAsync(string callId, CallRefusal refusal, Cancellation internal Task HangupAsync(string callId, CancellationToken cancellationToken) => SucceedsAsync(OpenAiLiveWire.HangupPath(callId), [], cancellationToken); + // A 2xx says only that OpenAI relayed the REFER, not that the line took the call (probe T1). + internal Task ReferAsync(string callId, Uri target, CancellationToken cancellationToken) => + SucceedsAsync(OpenAiLiveWire.ReferPath(callId), OpenAiLiveWire.ReferBody(target), cancellationToken); + /// Hangs up on its own ; a deadline or a cancel gives , never an exception. internal Task HangupQuietlyAsync(string callId) => QuietlyAsync(token => HangupAsync(callId, token)); diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs index 740e5249..319e5ab4 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs @@ -31,6 +31,11 @@ internal static class OpenAiLiveWire internal static string HangupPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/hangup"; + // Proven on a real call (probe T1): the peer gets an in-dialog REFER with Refer-To set to the target. + internal static string ReferPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/refer"; + + internal static JsonObject ReferBody(Uri target) => new() { ["target_uri"] = target.OriginalString }; + internal static Uri AttachUri(Uri apiBase, string callId) { ArgumentNullException.ThrowIfNull(apiBase); diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs b/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs index 48369edd..23b18e88 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs @@ -52,7 +52,7 @@ public void ATransferWithNoChannelIsNotSupportedAndLeavesTheConversationOpen() } [Fact] - public void TheAttachedChannelTakesATransferAndADetachedOneNoLonger() + public void TheAttachedChannelTakesATransfer() { using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); @@ -62,14 +62,11 @@ public void TheAttachedChannelTakesATransferAndADetachedOneNoLonger() session.Actions.Attach(channel); Assert.Equal(ConversationActionResult.Scheduled, session.Request(transfer)); Assert.Same(transfer, Assert.Single(channel.Seen)); - - session.Actions.Detach(channel); - Assert.Equal(ConversationActionResult.NotSupported, session.Request(transfer)); } - // A call that a newer call took over detaches late: it must not drop the newer call's channel. + // A newer call that takes the conversation over carries its actions from then on. [Fact] - public void DetachingAnOlderChannelKeepsTheNewerOne() + public void ANewerChannelReplacesTheOlderOne() { using ScriptedChatClient reply = new("hello"); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply); @@ -78,8 +75,6 @@ public void DetachingAnOlderChannelKeepsTheNewerOne() session.Actions.Attach(older); session.Actions.Attach(newer); - session.Actions.Detach(older); - Assert.Equal(ConversationActionResult.Scheduled, session.Request(new TransferAction(Staff))); Assert.Single(newer.Seen); Assert.Empty(older.Seen); diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs new file mode 100644 index 00000000..426d5660 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs @@ -0,0 +1,36 @@ +using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Runtime.Session; +using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Tests.Fakes; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Calls +{ + public sealed class PhoneCallChannelTests + { + private static readonly TransferAction Transfer = new(new Uri("tel:+15550002222")); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The tools of the next ask run on the session reopened after an idle unload, so the vendor's actions must be there. + [Fact] + public async Task TheChannelFollowsTheCallToASessionReopenedAfterAnUnload() + { + using PhoneCallHarness harness = PhoneCallHarness.Create(new StallOnCueChatClient(), []); + PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!; + await call.StartAsync(Ct); + call.Channel.Use(new AcceptingChannel(), call.Session); + Assert.Equal(ConversationActionResult.Scheduled, call.Session.Request(Transfer)); + await harness.Sessions.CloseAsync("main", "call-7", Ct); + + ConversationSession reopened = await call.LiveSessionAsync(Ct); + + Assert.Equal(ConversationActionResult.Scheduled, reopened.Request(Transfer)); + } + + private sealed class AcceptingChannel : IConversationChannel + { + public ConversationActionResult Request(ConversationAction action) => ConversationActionResult.Scheduled; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs index 0ca03ede..2623301e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs @@ -1,3 +1,4 @@ +using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Hooks; using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; @@ -41,17 +42,25 @@ private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCa /// Gets the hang-up, completed with how many events the test had pushed when the call hung up. public TaskCompletionSource HungUp { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Gets each refer the call sent, with its target. It completes on the first. + public TaskCompletionSource Referred { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Starts the call. With unset the call cannot transfer, as on a host that wired no + /// refer; set, each refer answers it. + /// public static async Task StartAsync( IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null, - string? greeting = Greeting) + string? greeting = Greeting, Func? tools = null, bool? refers = null) { CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(Noon) { Zone = TimeZoneInfo.Utc }; - PhoneCallHarness harness = PhoneCallHarness.Create(model, hooks, yaml, time: time); + PhoneCallHarness harness = PhoneCallHarness.Create(model, hooks, yaml, time: time, tools: tools); PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), ct)).Call!; await call.StartAsync(ct); RunningLiveCall running = new(harness, time, call, sideband ?? new FakeSideband()); - OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger ?? NullLogger.Instance, greeting); + Func>? refer = refers is { } answer ? (target, _) => running.ReferAsync(target, answer) : null; + OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger ?? NullLogger.Instance, greeting, refer); running.Loop = live.RunAsync(ct); return running; } @@ -63,6 +72,12 @@ public async ValueTask DisposeAsync() Harness.Dispose(); } + private Task ReferAsync(Uri target, bool answer) + { + _ = Referred.TrySetResult(target); + return Task.FromResult(answer); + } + private Task HangUpAsync(CancellationToken cancellationToken) { _ = HungUp.TrySetResult(Sideband.Pushed); diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs index 016134ca..fc8f7093 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveSettingsTests.cs @@ -5,7 +5,7 @@ namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive { - /// The live: block holds instructions, greeting, voice and model, and nothing else. + /// The live: block holds instructions, greeting, voice, model and transferSeconds, and nothing else. public sealed class OpenAiLiveSettingsTests { [Fact] @@ -30,7 +30,16 @@ public void AGreetingIsTakenAsWritten() OpenAiLiveSettings.From(Conversation("live: { instructions: \"Be brief.\", greeting: \"Say hello, then listen.\" }"))); } + [Fact] + public void TheTransferWaitIsTakenInSecondsAndDefaultsTo45() + { + Assert.Equal(TimeSpan.FromSeconds(45), OpenAiLiveSettings.From(Conversation("live: { instructions: \"x\" }")).TransferWait); + Assert.Equal(TimeSpan.FromSeconds(60), OpenAiLiveSettings.From(Conversation("live: { instructions: \"x\", transferSeconds: 60 }")).TransferWait); + } + [Theory] + [InlineData("live: { instructions: \"x\", transferSeconds: 0 }", "/providers/conversation/live/transferSeconds")] + [InlineData("live: { instructions: \"x\", transferSeconds: \"45\" }", "/providers/conversation/live/transferSeconds")] [InlineData("live: { voice: marin }", "/providers/conversation/live/instructions")] [InlineData("live: { instructions: \"x\", webhookSecret: \"${secret:OPENAI_WEBHOOK_SECRET}\" }", "/providers/conversation/live/webhookSecret")] [InlineData("answerSeconds: 5", "/providers/conversation/live")] diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs new file mode 100644 index 00000000..dcc2fb6e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs @@ -0,0 +1,163 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Actions; +using AgentCore.Application.Hooks.Notices; +using AgentCore.Application.Tools; +using AgentCore.Application.Tools.Binding; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.OpenAiLive; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.AspNetCore.Tests.Vendors.OpenAiLive.LiveSent; + +namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive +{ + /// + /// A tool asks for a transfer through its scope. Probe T1 (docs/probes/live-transfer-t1) set the rules: OpenAI never + /// reports the outcome, so the peer's close of the AI leg is the only success signal. + /// + public sealed class OpenAiLiveTransferTests + { + private static readonly Uri Staff = new("tel:+15550002222"); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // GPT-Live's ack marks the start of speech, so time alone must not send the refer before the answer was heard. + [Fact(Timeout = 30_000)] + public async Task TheReferWaitsUntilTheAnswerWasHeardAndACloseEndsTheCallAsTransferred() + { + RecordingHook hook = new(); + ConcurrentQueue answers = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new GatedToolCallingChatClient(), [hook], tools: TransferTool(answers), refers: true); + + running.Sideband.Push(Ask("item_1", "Can I talk to a person?", 1000)); + JsonObject answer = await running.Sideband.WaitForSentAsync(IsCommentary); + await running.Time.WaitForTimersAsync(RunningLiveCall.Noon + OpenAiLiveCall.AckWait, 1); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + bool referredBeforeSpeechStarted = running.Referred.Task.IsCompleted; + running.Sideband.Ack(answer); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + Uri target = await running.Referred.Task; + running.Sideband.Push([Heard("Hello?", 9000), Closed()]); + await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal([ConversationActionResult.Scheduled], answers); + Assert.False(referredBeforeSpeechStarted); + Assert.Equal(Staff, target); + Assert.False(running.HungUp.Task.IsCompleted); + Assert.Equal((ConversationEndReason.TransferredToHuman, OpenAiLiveCall.TransferredCause), (ended.Reason, ended.Call!.Cause)); + Assert.Contains((Speaker.Caller, "Hello?"), hook.Of().Select(line => (line.Speaker, line.Text))); + } + + // T1: a peer that declined the REFER still leaves OpenAI's answer at 200 and the session up. + [Fact(Timeout = 30_000)] + public async Task ACallStillUpWhenTheTransferWaitRunsOutIsHungUpAsAFailedTransfer() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new GatedToolCallingChatClient(), [hook], tools: TransferTool(new()), refers: true); + + await ReferAsync(running); + await running.Time.WaitForTimersAsync(running.Time.GetUtcNow() + OpenAiLiveSettings.DefaultTransferWait, 1); + bool hungUpEarly = running.HungUp.Task.IsCompleted; + running.Time.Advance(OpenAiLiveSettings.DefaultTransferWait); + _ = await running.HungUp.Task; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.False(hungUpEarly); + Assert.Equal((ConversationEndReason.Faulted, OpenAiLiveCall.TransferFailedCause), (ended.Reason, ended.Call!.Cause)); + } + + [Fact(Timeout = 30_000)] + public async Task ARefusedReferHangsUpAsAFailedTransfer() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new GatedToolCallingChatClient(), [hook], tools: TransferTool(new()), refers: false); + + await ReferAsync(running); + _ = await running.HungUp.Task; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal((ConversationEndReason.Faulted, OpenAiLiveCall.TransferFailedCause), (ended.Reason, ended.Call!.Cause)); + } + + // Once a transfer waits, the caller is leaving for the line: a question asked while the last answer is still + // being heard must not start a turn of its own. An ask opens its turn while the loop handles the delegation, + // and notices arrive in order, so a turn started for it would be recorded before the end. + [Fact(Timeout = 30_000)] + public async Task ADelegationWhileATransferWaitsStartsNoTurn() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new GatedToolCallingChatClient(), [hook], tools: TransferTool(new()), refers: true); + + running.Sideband.Push(Ask("item_1", "Can I talk to a person?", 1000)); + running.Sideband.Ack(await running.Sideband.WaitForSentAsync(IsCommentary)); + await running.Sideband.WaitForDrainAsync(); + running.Sideband.Push(Ask("item_2", "Are you still there?", 9000)); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + _ = await running.Referred.Task; + running.Sideband.Push([Closed()]); + await running.Loop; + _ = await hook.WaitForAsync(); + + Assert.Single(hook.Of()); + } + + [Fact(Timeout = 30_000)] + public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() + { + ConcurrentQueue answers = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync(new GatedToolCallingChatClient(), [], tools: TransferTool(answers)); + + running.Sideband.Push(Ask("item_1", "Can I talk to a person?", 1000)); + running.Sideband.Ack(await running.Sideband.WaitForSentAsync(IsCommentary)); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + + Assert.Equal([ConversationActionResult.NotSupported], answers); + Assert.False(running.Call.HasEnded); + } + + // Every tool of the harness's document asks for a transfer through the scope the binder fills, as a host's would. + private static Func TransferTool(ConcurrentQueue answers) => + tool => AIFunctionFactory.Create( + (ToolCallScope scope) => + { + ConversationActionResult answer = scope.Conversation.Request(new TransferAction(Staff)); + answers.Enqueue(answer); + return answer.ToString(); + }, + new AIFunctionFactoryOptions { Name = tool.Id, Description = tool.Description, ConfigureParameterBinding = ToolParameterBindings.For }); + + private static async Task ReferAsync(RunningLiveCall running) + { + running.Sideband.Push(Ask("item_1", "Can I talk to a person?", 1000)); + running.Sideband.Ack(await running.Sideband.WaitForSentAsync(IsCommentary)); + await running.Sideband.WaitForDrainAsync(); + running.Time.Advance(OpenAiLiveCall.EndQuietWait); + _ = await running.Referred.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + } + + private static string[] Ask(string delegationId, string words, int startMs) => + [ + Heard(words, startMs), + new JsonObject { ["type"] = OpenAiLiveEvents.DelegationCreated, ["delegation"] = new JsonObject { ["id"] = delegationId } }.ToJsonString(), + ]; + + private static string Heard(string words, int startMs) => + new JsonObject { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " " + words, ["start_ms"] = startMs, ["end_ms"] = startMs + 1000 }.ToJsonString(); + + private static string Closed() => new JsonObject { ["type"] = OpenAiLiveEvents.Closed, ["reason"] = "hangup" }.ToJsonString(); + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs index 88ed67cf..839f3af3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWireTests.cs @@ -43,6 +43,14 @@ public void AnotherEventIsNotACall() Assert.Null(OpenAiLiveWire.ReadIncomingCall(Encoding.UTF8.GetBytes("""{"type":"realtime.call.ended","data":{"call_id":"rtc_123"}}"""))); } + // The OpenAI SIP guide: POST /v1/live/sessions/{session_id}/refer with { "target_uri": "sip:agent@example.com" }. + [Fact] + public void AReferGoesToTheSessionsReferPathWithTheTargetUri() + { + Assert.Equal("v1/live/sessions/live_9/refer", OpenAiLiveWire.ReferPath("live_9")); + Assert.Equal("""{"target_uri":"sip:agent@example.com"}""", OpenAiLiveWire.ReferBody(new Uri("sip:agent@example.com")).ToJsonString()); + } + [Theory] [InlineData("\"Caller\" ;tag=1", "+15550100")] [InlineData("", "+15550100")] From a26e6c3328bd4c0084bbde229207982c952a4804 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 5 Oct 2026 01:04:11 -0600 Subject: [PATCH 5/6] feat: Introduce conversation routing and authentication for Telnyx and OpenAI Live vendors --- config/example.yaml | 6 +- .../PublicAPI.Unshipped.txt | 3 + .../Secrets/KnownSecrets.cs | 9 +++ .../DependencyInjection/AgentCoreBoot.cs | 12 +++- .../AgentCoreServiceCollectionExtensions.cs | 9 +++ .../Startup/ConversationSeamStartup.cs | 10 ++-- .../PublicAPI.Unshipped.txt | 26 +++++++-- .../OpenAiLiveConversationAdapter.cs | 7 +-- .../OpenAiLive/Webhook/OpenAiLiveWebhook.cs | 26 +++++++-- .../TelnyxRelayConversationAdapter.cs | 41 +++++++++++++- .../Voice/Ports/ConversationRoute.cs | 22 ++++++++ .../Ports/IConversationTransportAdapter.cs | 9 ++- .../Routing/CallerAuthenticationHandler.cs | 40 +++++++++++++ ...versationEndpointRouteBuilderExtensions.cs | 23 +++++++- .../AgentCoreHostBuilderExtensions.cs | 4 +- ...nyxRelayThroughTheConversationSeamTests.cs | 56 ++++++++++++------- .../Endpoints/EntryGateFactsTests.cs | 14 +++-- .../Fakes/EntryCounter.cs | 19 +++++++ .../Fakes/TelnyxRelayHost.cs | 13 ++++- .../OpenAiLive/OpenAiLiveWebhookTests.cs | 21 ++++++- .../TelnyxRelay/TelnyxRelayCallGateTests.cs | 2 +- .../Voice/ConversationRouteSelectionTests.cs | 28 +++------- .../AgentCoreHostRouteTests.cs | 3 +- .../AgentCoreHostTests.cs | 12 ++-- .../HostingTestHost.cs | 11 ++-- 25 files changed, 332 insertions(+), 94 deletions(-) create mode 100644 src/AgentCore.AspNetCore/Voice/Ports/ConversationRoute.cs create mode 100644 src/AgentCore.AspNetCore/Voice/Routing/CallerAuthenticationHandler.cs create mode 100644 tests/AgentCore.AspNetCore.Tests/Fakes/EntryCounter.cs diff --git a/config/example.yaml b/config/example.yaml index 357ae211..cab7f2fa 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -364,10 +364,14 @@ providers: # conversation the media pipe. It carries the audio and owns the route /v1/{entry}/call. # speech recognition (stt) and synthesis (tts), one role at a time. conversation: { kind: telnyx-relay } + # The call route only answers a caller that proves it is the vendor. Telnyx signs no relay socket, so its + # TeXML URL carries a shared key: wss:///v1//call?key=. Without the + # TELNYX_RELAY_KEY secret the host does not start. # The GPT-Live phone path instead. It speaks for itself, so it needs no speech: block. AgentCore adds # no prompt text of its own: GPT-Live hears only what this block says, plus the host's per-call brief. # Without a "never guess, delegate" paragraph GPT-Live made up product facts in 4 of 6 test calls, - # and it cannot hang up, so tell it to pass an end request to the backend. + # and it cannot hang up, so tell it to pass an end request to the backend. OpenAI signs every call's + # webhook, and the route checks it with the OPENAI_WEBHOOK_SECRET secret. # conversation: # kind: openai-live # live: diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index be39bb6d..b63eddc1 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -3158,3 +3158,6 @@ static AgentCore.Application.Conversation.Actions.TransferAction.operator !=(Age virtual AgentCore.Application.Conversation.Actions.ConversationAction.EqualityContract.get -> System.Type! virtual AgentCore.Application.Conversation.Actions.ConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool virtual AgentCore.Application.Conversation.Actions.ConversationAction.PrintMembers(System.Text.StringBuilder! builder) -> bool +const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyName = "telnyx-relay-key" -> string! +const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyVariable = "TELNYX_RELAY_KEY" -> string! +static readonly AgentCore.Application.Secrets.KnownSecrets.TelnyxRelay -> AgentCore.Application.Secrets.SecretName! diff --git a/src/AgentCore.Application/Secrets/KnownSecrets.cs b/src/AgentCore.Application/Secrets/KnownSecrets.cs index 2c4af907..c5b6e513 100644 --- a/src/AgentCore.Application/Secrets/KnownSecrets.cs +++ b/src/AgentCore.Application/Secrets/KnownSecrets.cs @@ -17,6 +17,12 @@ public static class KnownSecrets /// The environment variable the OpenAI webhook signing secret is read from. public const string OpenAiWebhookSecretVariable = "OPENAI_WEBHOOK_SECRET"; + /// The ${secret:name} name the Telnyx relay's shared key resolves under. + public const string TelnyxRelayKeyName = "telnyx-relay-key"; + + /// The environment variable the Telnyx relay's shared key is read from. + public const string TelnyxRelayKeyVariable = "TELNYX_RELAY_KEY"; + /// The ${secret:name} name the OpenCode Go key resolves under. public const string OpenCodeGoApiKeyName = "opencode-go-api-key"; @@ -65,6 +71,9 @@ public static class KnownSecrets /// The secret OpenAI signs its webhooks with (Standard Webhooks, whsec_…). The openai-live adapter checks every call with it. public static readonly SecretName OpenAiWebhook = new(OpenAiWebhookSecretName, OpenAiWebhookSecretVariable); + /// The key the Telnyx relay's socket URL carries as ?key=. /// + public static readonly SecretName TelnyxRelay = new(TelnyxRelayKeyName, TelnyxRelayKeyVariable); + /// The OpenCode Go credential, which the chat adapter sends as its bearer token. public static readonly SecretName OpenCodeGo = new(OpenCodeGoApiKeyName, OpenCodeGoApiKeyVariable); diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs index 10813f19..d5b4cbf2 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs @@ -101,7 +101,7 @@ public AgentCoreBoot(IOptions options, ILoggerFactory loggers, internal IBlobStore? Blobs => Started.Blobs; /// Gets what the conversation route runs, or when no conversation routes here. - internal RequestDelegate? ConversationHandler => Started.ConversationHandler; + internal ConversationRoute? ConversationRoute => Started.ConversationRoute; /// Gets why no conversation routes here, or when conversations route. internal string? ConversationUnroutable => Started.ConversationUnroutable; @@ -278,6 +278,12 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken) ConversationSeamAdapters seams = ConversationSeamStartup.Build(configuration, _options); + // A caller check whose secret is missing stops the host here, rather than refusing every call later. + if (seams.Route is { } route) + { + await route.Ready.ConfigureAwait(false); + } + ConversationSessionSeam conversation = ConversationSessionStartup.Open(this, _options, graph, auditQueue, _loggers); Started = new BootState( @@ -294,7 +300,7 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken) knowledge, seams.Conversation, seams.Speech, - seams.Handler, + seams.Route, seams.Unroutable, runtime); } @@ -376,7 +382,7 @@ private sealed record BootState( IKnowledgeRetrievalPort? Knowledge, IReadOnlyList? ConversationAdapters, IReadOnlyList? SpeechAdapters, - RequestDelegate? ConversationHandler, + ConversationRoute? ConversationRoute, string? ConversationUnroutable, HookRuntime Hooks); } diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs index 97b11e42..82f6e227 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs @@ -12,6 +12,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.WebSockets; using Microsoft.Extensions.DependencyInjection; +using AgentCore.AspNetCore.Voice.Routing; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -84,6 +86,13 @@ public static IServiceCollection AddAgentCore( provider.GetRequiredService>().Value.TimeProvider ?? TimeProvider.System); + // The call route requires this scheme. It never becomes the default, so a host's own sign-in stays in charge + // of every other route. Registered after the clock: AddAuthentication registers the system clock if none is, + // and would win over AgentCoreOptions.TimeProvider. + _ = services.AddAuthentication() + .AddScheme(ConversationEndpointRouteBuilderExtensions.CallerScheme, configureOptions: null); + _ = services.AddAuthorization(); + services.TryAddSingleton(provider => provider.GetRequiredService>().Value.Cache ?? PassThroughHybridCache.Instance); diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs index f81a6da4..58523c6d 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs @@ -18,19 +18,19 @@ namespace AgentCore.AspNetCore.DependencyInjection.Startup /// carries text, so it is itself both and there is nothing to build. The list goes in the container /// beside the document so that a vendor which does need constructing has somewhere to be found. /// - /// - /// What the MapCall route runs for every entry, or when this + /// + /// What the MapCall route runs for every entry, and how it checks its caller, or when this /// document routes no inbound conversation — the host registered no transport, wrote no /// providers.conversation block, or named a vendor this process dials out to, which has no inbound URL. /// /// - /// Why is , in the words a deployer can act on, or + /// Why is , in the words a deployer can act on, or /// when conversations route. /// internal readonly record struct ConversationSeamAdapters( IReadOnlyList? Conversation, IReadOnlyList? Speech, - RequestDelegate? Handler, + ConversationRoute? Route, string? Unroutable); /// The two provider blocks a conversation arrives on: providers.conversation and providers.speech. @@ -85,7 +85,7 @@ internal static ConversationSeamAdapters Build( options.Speech, null, $"'{selectedConversation.Kind}' is a vendor this process dials out to, so it answers no inbound route") - : new ConversationSeamAdapters(conversationAdapters, options.Speech, transport.CreateHandler(conversationEntry), null); + : new ConversationSeamAdapters(conversationAdapters, options.Speech, transport.CreateRoute(conversationEntry), null); } /// Refuses the knobs of AgentCore's own turn taking for a vendor that takes turns itself. diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt index 0256b749..e77802dc 100644 --- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt +++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt @@ -1,7 +1,6 @@ #nullable enable AgentCore.AspNetCore.Voice.Routing.ConversationEndpointRouteBuilderExtensions AgentCore.AspNetCore.Voice.Ports.IConversationTransportAdapter -AgentCore.AspNetCore.Voice.Ports.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! @@ -63,9 +62,7 @@ override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.GetSessionAsyn 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.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! AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.Kind.get -> string! -AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRelayConversationAdapter() -> void AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.Kind.get -> string! AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.TelnyxRelaySpeechAdapter() -> void @@ -216,7 +213,6 @@ static AgentCore.AspNetCore.DependencyInjection.AgentCoreHookOptions.UseHooks(th static AgentCore.AspNetCore.DependencyInjection.AgentCoreHookOptions.UseHooks(this AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! options) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.CarriesText.get -> bool -AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate! AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.Kind.get -> string! AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.OpenAiLiveConversationAdapter(System.Net.Http.IHttpMessageHandlerFactory! handlers, AgentCore.Application.Ports.ISecretResolverPort? secrets = null) -> void AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.Traits.get -> AgentCore.AspNetCore.Voice.Ports.CallTraits @@ -228,3 +224,25 @@ AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Headers.get -> System AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.Headers.init -> void AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.To.get -> string? AgentCore.AspNetCore.Voice.Ports.ConversationInput.Started.To.init -> void +AgentCore.AspNetCore.Vendors.OpenAiLive.OpenAiLiveConversationAdapter.CreateRoute(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> AgentCore.AspNetCore.Voice.Ports.ConversationRoute! +AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.CreateRoute(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> AgentCore.AspNetCore.Voice.Ports.ConversationRoute! +AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRelayConversationAdapter(System.Func? secrets = null) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationRoute +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.$() -> AgentCore.AspNetCore.Voice.Ports.ConversationRoute! +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.ConversationRoute(Microsoft.AspNetCore.Http.RequestDelegate! Handler, System.Func>! IsVendor) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Deconstruct(out Microsoft.AspNetCore.Http.RequestDelegate! Handler, out System.Func>! IsVendor) -> void +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Equals(AgentCore.AspNetCore.Voice.Ports.ConversationRoute? other) -> bool +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Handler.get -> Microsoft.AspNetCore.Http.RequestDelegate! +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Handler.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.IsVendor.get -> System.Func>! +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.IsVendor.init -> void +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Ready.get -> System.Threading.Tasks.Task! +AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Ready.init -> void +AgentCore.AspNetCore.Voice.Ports.IConversationTransportAdapter.CreateRoute(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> AgentCore.AspNetCore.Voice.Ports.ConversationRoute! +const AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.KeyParameter = "key" -> string! +const AgentCore.AspNetCore.Voice.Routing.ConversationEndpointRouteBuilderExtensions.CallerScheme = "AgentCore.Caller" -> string! +override AgentCore.AspNetCore.Voice.Ports.ConversationRoute.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.Ports.ConversationRoute.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.Ports.ConversationRoute.ToString() -> string! +static AgentCore.AspNetCore.Voice.Ports.ConversationRoute.operator ==(AgentCore.AspNetCore.Voice.Ports.ConversationRoute? left, AgentCore.AspNetCore.Voice.Ports.ConversationRoute? right) -> bool +static AgentCore.AspNetCore.Voice.Ports.ConversationRoute.operator !=(AgentCore.AspNetCore.Voice.Ports.ConversationRoute? left, AgentCore.AspNetCore.Voice.Ports.ConversationRoute? right) -> bool diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs index 7b79db15..19e5fc1c 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveConversationAdapter.cs @@ -3,7 +3,6 @@ using AgentCore.AspNetCore.Vendors.OpenAiLive.Webhook; using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; using AgentCore.AspNetCore.Voice.Ports; -using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.Vendors.OpenAiLive { @@ -78,12 +77,12 @@ private OpenAiLiveConversationAdapter( /// public CallTraits Traits => CallTraits.SpeaksForItself | CallTraits.TakesTurnsItself; - /// Gets the keys being resolved once ran, or . + /// Gets the keys being resolved once ran, or . internal Task? Credentials { get; private set; } /// /// The live: block is missing or wrong. - public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + public ConversationRoute CreateRoute(ConversationProviderConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); @@ -94,7 +93,7 @@ public RequestDelegate CreateHandler(ConversationProviderConfiguration configura // The pipeline owns the handler chain, and other clients send on it, so this client disposes nothing. HttpClient http = new(_handlers.CreateHandler(HttpClientName), disposeHandler: false) { BaseAddress = _apiBase }; OpenAiLiveWebhook webhook = new(settings, configuration, credentials, new OpenAiLiveControl(http, credentials), _attach, _apiBase); - return webhook.HandleAsync; + return new ConversationRoute(webhook.HandleAsync, webhook.IsOpenAiAsync) { Ready = credentials }; } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs index 5b0fa580..0b2cc71c 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -39,6 +39,7 @@ internal async Task HandleAsync(HttpContext http) OpenAiLiveCalls calls = services.GetRequiredService(); OpenAiLiveCredentials keys = await credentials.ConfigureAwait(false); + // IsOpenAiAsync already checked the signature over this body. if (await ReadBodyAsync(http.Request, http.RequestAborted).ConfigureAwait(false) is not { } body) { http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; @@ -46,12 +47,6 @@ internal async Task HandleAsync(HttpContext http) } string? webhookId = Header(http, "webhook-id"); - if (!StandardWebhookSignature.Verify(keys.WebhookKey, webhookId, Header(http, "webhook-timestamp"), body, Header(http, "webhook-signature"), time.GetUtcNow())) - { - OpenAiLiveLog.SignatureRefused(logger); - http.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } http.Response.StatusCode = StatusCodes.Status200OK; if (!calls.TryClaimWebhook(webhookId!, time.GetUtcNow()) @@ -193,6 +188,25 @@ private async Task HangUpAsync(string callId, ILogger logger) private static string? Header(HttpContext http, string name) => http.Request.Headers.TryGetValue(name, out StringValues value) ? value.ToString() : null; + /// Leaves the body buffered, because reads it again. + internal async ValueTask IsOpenAiAsync(HttpContext http) + { + OpenAiLiveCredentials keys = await credentials.ConfigureAwait(false); + http.Request.EnableBuffering(); + byte[]? body = await ReadBodyAsync(http.Request, http.RequestAborted).ConfigureAwait(false); + http.Request.Body.Position = 0; + + TimeProvider time = http.RequestServices.GetRequiredService(); + if (body is not null + && StandardWebhookSignature.Verify(keys.WebhookKey, Header(http, "webhook-id"), Header(http, "webhook-timestamp"), body, Header(http, "webhook-signature"), time.GetUtcNow())) + { + return true; + } + + OpenAiLiveLog.SignatureRefused(http.RequestServices.GetRequiredService().CreateLogger("AgentCore.OpenAiLive")); + return false; + } + private static async Task ReadBodyAsync(HttpRequest request, CancellationToken cancellationToken) { using MemoryStream body = new(); diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs index fd81e831..bd33733f 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs @@ -2,6 +2,10 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.AspNetCore.Voice.Options; using AgentCore.AspNetCore.Voice.Ports; +using System.Security.Cryptography; +using System.Text; +using AgentCore.Application.Ports; +using AgentCore.Application.Secrets; using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay @@ -9,8 +13,15 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay /// /// The Telnyx Conversation Relay as a conversation transport: it owns the socket and speaks Telnyx frames. /// - public sealed class TelnyxRelayConversationAdapter : IConversationTransportAdapter + /// + /// Reads the host's secret resolver when the route is built, or to read + /// from the environment only. + /// + public sealed class TelnyxRelayConversationAdapter(Func? secrets = null) : IConversationTransportAdapter { + /// The query parameter the socket URL carries the shared key in. + public const string KeyParameter = "key"; + /// The one providers.conversation.kind value this vendor answers to. public const string TelnyxRelayKind = "telnyx-relay"; @@ -21,12 +32,36 @@ public sealed class TelnyxRelayConversationAdapter : IConversationTransportAdapt public bool CarriesText => true; /// - public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + public ConversationRoute CreateRoute(ConversationProviderConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); TelnyxRelayOptions options = BuildOptions(configuration); - return http => TelnyxRelayEndpointRouteBuilderExtensions.HandleAsync(http, options); + + // Telnyx signs no relay connect and sends no auth header, so the caller proves itself with a shared key in + // the socket URL: wss://host/v1/{entry}/call?key=…, set in the TeXML verb. + Task key = ResolveKeyAsync(secrets?.Invoke()); + return new ConversationRoute( + http => TelnyxRelayEndpointRouteBuilderExtensions.HandleAsync(http, options), + http => CarriesKeyAsync(http, key)) + { + Ready = key, + }; + } + + private static async Task ResolveKeyAsync(ISecretResolverPort? resolver) + { + string key = await resolver.RequireAsync( + KnownSecrets.TelnyxRelay, "Telnyx signs no relay socket, so this shared key is the route's only check of its caller.").ConfigureAwait(false); + return Encoding.UTF8.GetBytes(key); + } + + // Fixed time, so how long a refusal takes says nothing about how much of the key was right. + private static async ValueTask CarriesKeyAsync(HttpContext http, Task key) + { + byte[] expected = await key.ConfigureAwait(false); + byte[] given = Encoding.UTF8.GetBytes(http.Request.Query[KeyParameter].ToString()); + return CryptographicOperations.FixedTimeEquals(given, expected); } /// Turns the document's limits into the options the endpoint runs on. diff --git a/src/AgentCore.AspNetCore/Voice/Ports/ConversationRoute.cs b/src/AgentCore.AspNetCore/Voice/Ports/ConversationRoute.cs new file mode 100644 index 00000000..fccdad79 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/Ports/ConversationRoute.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace AgentCore.AspNetCore.Voice.Ports +{ + /// + /// The inbound route of a conversation vendor: what answers it, and how it knows the request came from the vendor. + /// They come as one value, so no vendor can answer a route it does not check. + /// + /// Answers the route, once the caller passed . + /// + /// Whether the request came from the vendor: its webhook signature, a shared key, whatever the vendor offers. It runs + /// before the route picks an entry. It may read the body, and must leave it readable for . + /// + public sealed record ConversationRoute(RequestDelegate Handler, Func> IsVendor) + { + /// + /// Gets what completes once the check holds the secrets it needs. The host does not start before it does, so a + /// missing secret stops the host and never leaves the route open. + /// + public Task Ready { get; init; } = Task.CompletedTask; + } +} diff --git a/src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs b/src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs index b3bae1f1..bf230278 100644 --- a/src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs +++ b/src/AgentCore.AspNetCore/Voice/Ports/IConversationTransportAdapter.cs @@ -1,6 +1,5 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.AspNetCore.Voice.Routing; -using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.Voice.Ports { @@ -9,13 +8,13 @@ namespace AgentCore.AspNetCore.Voice.Ports /// public interface IConversationTransportAdapter : IConversationAdapter { - /// Builds the handler the conversation route answers every entry with. + /// Builds the route every entry answers on, with the check that the caller is the vendor. /// The providers.conversation block, including its limits. /// - /// The delegate the conversation route runs. The route has already picked the entry and - /// refused one the document does not declare; the handler reads it with + /// The route. Its handler runs once the caller passed the check and the route picked the entry and refused one + /// the document does not declare; the handler reads it with /// . /// - RequestDelegate CreateHandler(ConversationProviderConfiguration configuration); + ConversationRoute CreateRoute(ConversationProviderConfiguration configuration); } } diff --git a/src/AgentCore.AspNetCore/Voice/Routing/CallerAuthenticationHandler.cs b/src/AgentCore.AspNetCore/Voice/Routing/CallerAuthenticationHandler.cs new file mode 100644 index 00000000..56f9f98a --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/Routing/CallerAuthenticationHandler.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using AgentCore.AspNetCore.DependencyInjection; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AgentCore.AspNetCore.Voice.Routing +{ + /// + /// The scheme the call route requires: the active vendor's own check that the request came from it. One scheme for + /// every vendor, because AgentCore learns which vendor runs only when the host starts and loads the document, after + /// the schemes are registered. + /// + internal sealed class CallerAuthenticationHandler( + IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + /// The claim that names the vendor the caller proved to be. + internal const string VendorClaim = "agentcore:vendor"; + + protected override async Task HandleAuthenticateAsync() + { + if (Context.RequestServices.GetService() is not { ConversationRoute: { } route } boot) + { + return AuthenticateResult.Fail("this host routes no inbound conversation."); + } + + if (!await route.IsVendor(Context).ConfigureAwait(false)) + { + return AuthenticateResult.Fail("the caller is not the conversation vendor."); + } + + string vendor = boot.Configuration.Providers?.Conversation?.Kind ?? string.Empty; + ClaimsIdentity identity = new([new Claim(VendorClaim, vendor)], Scheme.Name); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs b/src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs index 6e7ac3fc..bc84832a 100644 --- a/src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs +++ b/src/AgentCore.AspNetCore/Voice/Routing/ConversationEndpointRouteBuilderExtensions.cs @@ -2,6 +2,7 @@ using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Endpoints; using AgentCore.AspNetCore.Voice.Diagnostics; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -23,6 +24,12 @@ public static class ConversationEndpointRouteBuilderExtensions /// The route every entry answers on when the host names none. public const string DefaultPattern = "/v1/{" + EntryRouteParameter + "}/call"; + /// + /// The authentication scheme the call route requires: the conversation vendor's own proof that a request came + /// from it. A host's sign-in leaves an endpoint that names this scheme to it. + /// + public const string CallerScheme = "AgentCore.Caller"; + /// Maps every entry on , with the URL naming the entry. /// The route builder of the host. /// The mapped endpoint, so a host adds its own conventions. @@ -43,9 +50,21 @@ public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endp ArgumentNullException.ThrowIfNull(endpoints); ArgumentException.ThrowIfNullOrEmpty(pattern); + // The route requires the caller scheme AddAgentCore registers: without it every call would fail on its own, + // so the host fails here, where the fix is one line. + if (endpoints.ServiceProvider.GetService()?.IsService(typeof(AgentCoreBoot)) != true) + { + throw new InvalidOperationException( + "app.MapCall() needs services.AddAgentCore(...): the call route requires the caller check AgentCore registers."); + } + // Map, and not MapGet. An HTTP/2 WebSocket arrives as CONNECT rather than GET, and MapGet // would answer 405 to it. - return endpoints.Map(pattern, http => DispatchAsync(http, pattern)).WithMetadata(AgentCoreRouteMetadata.Call); + return endpoints.Map(pattern, http => DispatchAsync(http, pattern)) + .WithMetadata(AgentCoreRouteMetadata.Call) + // The attribute form and not a policy object: a host middleware reads the scheme name off the + // endpoint's IAuthorizeData, and a policy object shows it none (docs/probes/call-route-auth). + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = CallerScheme }); } /// Reads the entry this request runs. @@ -70,7 +89,7 @@ private static async Task DispatchAsync(HttpContext http, string pattern) return; } - if (boot.ConversationHandler is not { } handler) + if (boot.ConversationRoute is not { Handler: var handler }) { await NotRoutedAsync(http, pattern, boot.ConversationUnroutable ?? "this host routes no inbound conversation") .ConfigureAwait(false); diff --git a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs index 07d03220..ebb12ea5 100644 --- a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs @@ -111,9 +111,9 @@ private static void Configure( _ = options.UseTelemetry(new GrafanaOtlpTelemetryAdapter()); + // The host's own callback runs after this one and may set another resolver, so each adapter reads it late. _ = options.UseConversation( - new TelnyxRelayConversationAdapter(), - // The host's own callback runs after this one and may set another resolver. + new TelnyxRelayConversationAdapter(() => options.SecretResolver), new OpenAiLiveConversationAdapter(httpClients, () => options.SecretResolver)); _ = options.UseSpeech(new TelnyxRelaySpeechAdapter()); diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/TelnyxRelayThroughTheConversationSeamTests.cs b/tests/AgentCore.AspNetCore.Tests/Conversation/TelnyxRelayThroughTheConversationSeamTests.cs index a1d0b50b..dac8371e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/TelnyxRelayThroughTheConversationSeamTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Conversation/TelnyxRelayThroughTheConversationSeamTests.cs @@ -1,7 +1,10 @@ using System.Net; +using AgentCore.Application.Secrets; +using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.TestSupport; using Xunit; namespace AgentCore.AspNetCore.Tests.Conversation @@ -9,23 +12,6 @@ namespace AgentCore.AspNetCore.Tests.Conversation /// /// The one place the shipped vendor and the vendor-neutral route meet in a running host. /// - /// - /// - /// ConversationRouteSelectionTests proves the selection over fakes and names no vendor, which is - /// spec §12. ConversationOptionsFromDocumentTests proves - /// TelnyxRelayConversationAdapter.BuildOptions over a document. Neither of them runs - /// TelnyxRelayConversationAdapter.Map, and that method is the single executable line joining the - /// shipped vendor to the seam. This file is where it runs. - /// - /// - /// This file may name Telnyx freely: joining the real vendor to the real route is precisely what it - /// exists to prove, and a version of it written over a fake would prove nothing new. - /// - /// - /// It runs offline against a fake model. There is no Telnyx account, no network conversation, and no API - /// key here. - /// - /// public sealed class TelnyxRelayThroughTheConversationSeamTests { [Fact(Timeout = 30_000)] @@ -38,9 +24,9 @@ public async Task APlainGetToTheVendorNeutralRoute_ReachesTheRelayHandler() await using TelnyxRelayHost host = await TelnyxRelayHost.StartThroughConversationSeamAsync( TelnyxRelayTurnTests.PolicyYaml, reply, - options => options.UseConversation(new TelnyxRelayConversationAdapter())); + options => options.UseConversation(TelnyxRelayHost.KeyedAdapter())); - HttpResponseMessage answer = await host.GetAsync(TelnyxRelayHost.MainConversation); + HttpResponseMessage answer = await host.GetAsync(TelnyxRelayHost.KeyedMainConversation); // 404 would mean MapCall mapped nothing, and every call to this deployment would be lost // with nothing to read. 400 is the relay's own HandleAsync refusing a request that is not a @@ -49,5 +35,37 @@ public async Task APlainGetToTheVendorNeutralRoute_ReachesTheRelayHandler() Assert.NotEqual(HttpStatusCode.NotFound, answer.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, answer.StatusCode); } + + // Telnyx signs no relay socket, so the key in its URL is the only proof a caller is Telnyx. + [Theory(Timeout = 30_000)] + [InlineData(TelnyxRelayHost.MainConversation)] + [InlineData(TelnyxRelayHost.MainConversation + "?key=wrong")] + [InlineData(TelnyxRelayHost.MainConversation + "?key=relay-key-")] + public async Task ACallerWithoutTheRelayKeyIsRefusedBeforeTheRelayOrTheEntryGate(string route) + { + using FragmentingChatClient reply = new("hello"); + EntryCounter entries = new(); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartThroughConversationSeamAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + options => options.UseConversation(TelnyxRelayHost.KeyedAdapter()).UseHooks(entries)); + + HttpResponseMessage answer = await host.GetAsync(route); + + Assert.Equal(HttpStatusCode.Unauthorized, answer.StatusCode); + Assert.Equal(0, entries.Count); + } + + // A relay route with no key to check would be open to anyone, so the host does not start. + [Fact(Timeout = 30_000)] + public async Task AHostWhoseRelayHasNoKeyDoesNotStart() + { + using FragmentingChatClient reply = new("hello"); + + _ = await Assert.ThrowsAsync(() => TelnyxRelayHost.StartThroughConversationSeamAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + options => options.UseConversation(new TelnyxRelayConversationAdapter(() => new MapSecretResolver())))); + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs index 7b1cd758..85154759 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs @@ -154,13 +154,15 @@ private sealed class HandlerProbe : IConversationTransportAdapter public bool Ran { get; private set; } - public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + public ConversationRoute CreateRoute(ConversationProviderConfiguration configuration) { - return _ => - { - Ran = true; - return Task.CompletedTask; - }; + return new ConversationRoute( + _ => + { + Ran = true; + return Task.CompletedTask; + }, + _ => ValueTask.FromResult(true)); } } diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/EntryCounter.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/EntryCounter.cs new file mode 100644 index 00000000..9e7dd31c --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/EntryCounter.cs @@ -0,0 +1,19 @@ +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Counts the requests that reached the entry gate, and decides nothing. + internal sealed class EntryCounter : AgentHook + { + private int _count; + + public int Count => Volatile.Read(ref _count); + + public override ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) + { + _ = Interlocked.Increment(ref _count); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs index b2511be3..1c2ff280 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs @@ -1,5 +1,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Ports; +using AgentCore.Application.Secrets; using AgentCore.Application.Runtime.Session; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; @@ -28,6 +29,16 @@ internal sealed class TelnyxRelayHost : IAsyncDisposable /// The relay's own route, with the document's one entry filled in. public const string MainRelay = "/v1/main/telnyx/relay"; + /// The shared key a relay call carries in its URL. + public const string RelayKey = "relay-key-1"; + + /// The vendor-neutral conversation route with the relay's key, as Telnyx would dial it. + public const string KeyedMainConversation = MainConversation + "?" + TelnyxRelayConversationAdapter.KeyParameter + "=" + RelayKey; + + /// A Telnyx adapter that holds , as a host's secret resolver would give it. + public static TelnyxRelayConversationAdapter KeyedAdapter() + => new(() => new MapSecretResolver().With(KnownSecrets.TelnyxRelayKeyName, RelayKey)); + private readonly WebApplication _app; private readonly HttpClient _client; private readonly ErrorCapturingLoggerProvider _errors; @@ -151,7 +162,7 @@ private static async Task StartCoreAsync( { // The vendor-neutral seam picks the transport out of providers.conversation and the adapter the // test registered. Nothing here names a route or a vendor. - route = MainConversation; + route = KeyedMainConversation; _ = app.MapCall(); } else diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs index 151210c4..7fe69792 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs @@ -5,6 +5,9 @@ using AgentCore.Application.Hooks.Notices; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Routing; using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -71,15 +74,31 @@ public async Task AHookThatThrowsRejectsTheCallAsUnavailable() public async Task ABadSignatureIsRefusedBeforeAnyHookRuns() { bool asked = false; - await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [new CallDecider(_ => asked = true)]); + EntryCounter entries = new(); + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [entries, new CallDecider(_ => asked = true)]); HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), secret: "whsec_" + Convert.ToBase64String(new byte[24])); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.False(asked); + Assert.Equal(0, entries.Count); Assert.Empty(host.Control.Requests); } + // A host's sign-in middleware finds the scheme only through IAuthorizeData (docs/probes/call-route-auth). + [Fact(Timeout = 30_000)] + public async Task TheCallRouteNamesTheCallerSchemeWhereAHostsSignInCanReadIt() + { + await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), []); + + RouteEndpoint call = host.Services.GetRequiredService().Endpoints.OfType() + .Single(endpoint => endpoint.RoutePattern.RawText == ConversationEndpointRouteBuilderExtensions.DefaultPattern); + + Assert.Equal( + [ConversationEndpointRouteBuilderExtensions.CallerScheme], + call.Metadata.GetOrderedMetadata().Select(data => data.AuthenticationSchemes)); + } + [Fact(Timeout = 30_000)] public async Task AStaleTimestampIsRefused() { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs index 77c95102..21fb13ae 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs @@ -105,7 +105,7 @@ public async Task TheBriefOfARelayCallReachesTheEngine() await using TelnyxRelayHost host = await TelnyxRelayHost.StartThroughConversationSeamAsync( TelnyxRelayTurnTests.PolicyYaml, reply, - options => options.UseConversation(new TelnyxRelayConversationAdapter()).UseHooks(new CallDecider(gate => gate.Accept("thread-2", "BRIEF-X")))); + options => options.UseConversation(TelnyxRelayHost.KeyedAdapter()).UseHooks(new CallDecider(gate => gate.Accept("thread-2", "BRIEF-X")))); await using FakeRelayClient relay = await host.ConnectAsync(); await relay.SendAsync(RelayFrames.Setup()); diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs index e87d5870..aea9876c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs @@ -28,7 +28,7 @@ public void TheTransportTheDocumentNamesIsAskedForItsHandler() ConversationSeamAdapters seams = Build(conversationKind: "bundled-fake", transport); - Assert.NotNull(seams.Handler); + Assert.NotNull(seams.Route); // The block handed over is the providers.conversation entry of this document, not null and not some // empty stand-in. The kind is what proves which entry it is. @@ -58,7 +58,7 @@ public void ADialOutVendorRoutesNothingAndSaysWhy() // This case must route nothing AND say so. A route that vanishes in silence is // how a deployment loses every call to a 404 with nothing to read. - Assert.Null(seams.Handler); + Assert.Null(seams.Route); Assert.NotNull(seams.Unroutable); Assert.Contains("dial-out-fake", seams.Unroutable, StringComparison.Ordinal); } @@ -69,31 +69,21 @@ public void AHostThatRegisteredNoTransportRoutesNothingAndSaysWhy() ConversationSeamAdapters seams = ConversationSeamStartup.Build( ConfigurationLoader.LoadYaml(Document("bundled-fake")), new AgentCoreOptions()); - Assert.Null(seams.Handler); + Assert.Null(seams.Route); Assert.NotNull(seams.Unroutable); Assert.Contains("no conversation adapter", seams.Unroutable, StringComparison.Ordinal); } [Fact] - public async Task AHostWithNoAgentCoreRegistrationAnswersTheRouteWithAReason() + public void MappingTheCallRouteOnAHostWithNoAgentCoreRegistrationFailsAtStartup() { WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); _ = builder.Logging.ClearProviders(); - _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); + WebApplication app = builder.Build(); - await using WebApplication app = builder.Build(); - _ = app.MapCall(); - await app.StartAsync(TestContext.Current.CancellationToken); + InvalidOperationException failure = Assert.Throws(() => app.MapCall()); - using HttpClient client = new() { BaseAddress = new Uri(Address(app)) }; - HttpResponseMessage response = await client.GetAsync("/v1/main/call", TestContext.Current.CancellationToken); - - // A readable refusal, and not the 404 a route that mapped nothing would have produced. - Assert.Equal(System.Net.HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.Contains( - "registered no AgentCore services", - await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken), - StringComparison.Ordinal); + Assert.Contains("AddAgentCore", failure.Message, StringComparison.Ordinal); } [Fact] @@ -161,10 +151,10 @@ private sealed class FakeTransport(string kind) : IConversationTransportAdapter public ConversationProviderConfiguration? Configuration { get; private set; } - public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + public ConversationRoute CreateRoute(ConversationProviderConfiguration configuration) { Configuration = configuration; - return _ => Task.CompletedTask; + return new ConversationRoute(_ => Task.CompletedTask, _ => ValueTask.FromResult(true)); } } diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs index 6c6ca9b3..80102654 100644 --- a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs @@ -81,7 +81,8 @@ public async Task ConversationRefusesAnEntryTheDocumentDoesNotDeclare() await using WebApplication app = await StartMappedAsync(); using HttpClient client = new() { BaseAddress = Address(app) }; - HttpResponseMessage response = await client.GetAsync("/v1/nobody/call", TestContext.Current.CancellationToken); + // Without the relay's key the caller is refused before the route reads the entry. + HttpResponseMessage response = await client.GetAsync("/v1/nobody/call?key=" + HostingTestHost.RelayKey, TestContext.Current.CancellationToken); Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); Assert.Contains( diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs index 21d4fd69..53f7d3e8 100644 --- a/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs @@ -344,15 +344,17 @@ private static async Task StartAsync( return app; } - /// A chain that holds the connection string and answers nothing for it. + /// Answers an empty connection string and the relay's key, and nothing else. private sealed class EmptySecretResolver : ISecretResolverPort { public ValueTask TryResolveAsync(string name, CancellationToken cancellationToken = default) { - return ValueTask.FromResult( - string.Equals(name, KnownSecrets.PostgresConnectionString.Name, StringComparison.Ordinal) - ? string.Empty - : null); + return ValueTask.FromResult(name switch + { + KnownSecrets.PostgresConnectionStringName => string.Empty, + KnownSecrets.TelnyxRelayKeyName => HostingTestHost.RelayKey, + _ => null, + }); } } diff --git a/tests/AgentCore.Hosting.Tests/HostingTestHost.cs b/tests/AgentCore.Hosting.Tests/HostingTestHost.cs index 07ef7a94..bf376248 100644 --- a/tests/AgentCore.Hosting.Tests/HostingTestHost.cs +++ b/tests/AgentCore.Hosting.Tests/HostingTestHost.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Secrets; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.TestSupport; using Microsoft.AspNetCore.Builder; @@ -11,13 +12,10 @@ namespace AgentCore.Hosting.Tests /// The one host every test in this project builds, over a fake model and no key. internal static class HostingTestHost { + /// The shared key the document's Telnyx relay checks callers with: without one the host does not start. + internal const string RelayKey = "test-relay-key"; + /// The smallest document the schema accepts: one agent, one model, and the required pair. - /// - /// A document that writes a providers block at all must write conversation and - /// speech too, and both speech roles must name the same vendor the conversation does. Those kinds - /// resolve here because the default list this library registers already names that transport — - /// which is the point: a test writes no vendor of its own except the model. - /// internal const string Document = """ apiVersion: agentcore/v1 agents: @@ -54,6 +52,7 @@ internal static async Task BuildAsync( _ = builder.AddAgentCoreHost(options => { options.Configuration = ConfigurationLoader.LoadYaml(document); + options.SecretResolver = new MapSecretResolver().With(KnownSecrets.TelnyxRelayKeyName, RelayKey); _ = options.UseChatClients(_ => new RecordingChatClientFactory(new FakeChatClient())); configure?.Invoke(options); }); From 7bf09f9304c1f3bcb5d0a2fa218153e64ff4b585 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 5 Oct 2026 13:05:24 -0600 Subject: [PATCH 6/6] feat: Implement call transfer functionality with host support --- .../Conversation/Actions/CallTransfer.cs | 18 +++ .../Actions/CallTransferOutcome.cs | 12 ++ .../Conversation/Actions/ICallTransfer.cs | 17 ++ .../Conversation/Actions/TransferAction.cs | 8 + .../PublicAPI.Unshipped.txt | 22 +++ .../OpenAiLive/Call/ILiveTransferLine.cs | 15 ++ .../Vendors/OpenAiLive/Call/LiveCallEnd.cs | 51 +++++- .../Vendors/OpenAiLive/Call/LiveCallFinish.cs | 60 +++++++ .../Vendors/OpenAiLive/Call/LiveHandover.cs | 8 + .../OpenAiLive/Call/LiveHandoverOutcome.cs | 15 ++ .../OpenAiLive/Call/LiveHostTransfer.cs | 32 ++++ .../Vendors/OpenAiLive/Call/LivePeerClose.cs | 49 ++++++ .../OpenAiLive/Call/LiveReferTransfer.cs | 36 +++++ .../Vendors/OpenAiLive/Call/LiveTransfer.cs | 32 ++-- .../Vendors/OpenAiLive/Call/OpenAiLiveCall.cs | 148 ++++++------------ .../Vendors/OpenAiLive/OpenAiLiveCalls.cs | 7 + .../Vendors/OpenAiLive/OpenAiLiveLog.cs | 5 +- .../OpenAiLive/Webhook/OpenAiLiveWebhook.cs | 7 +- .../Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs | 3 + .../Fakes/RunningLiveCall.cs | 28 +++- .../OpenAiLive/OpenAiLiveTransferTests.cs | 40 ++++- 21 files changed, 479 insertions(+), 134 deletions(-) create mode 100644 src/AgentCore.Application/Conversation/Actions/CallTransfer.cs create mode 100644 src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs create mode 100644 src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallFinish.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandover.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandoverOutcome.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LivePeerClose.cs create mode 100644 src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs b/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs new file mode 100644 index 00000000..00c9f180 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs @@ -0,0 +1,18 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// One transfer an carries out. + public sealed record CallTransfer + { + /// Gets the conversation the call carries. + public required string ConversationId { get; init; } + + /// Gets the line, as the named it. + public required Uri Target { get; init; } + + /// + /// Gets the call's own transport headers, such as SIP headers, by name, case-insensitive. A host finds its phone + /// system's id for the call here when the system that placed the call put it in. + /// + public required IReadOnlyDictionary Headers { get; init; } + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs b/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs new file mode 100644 index 00000000..3491d318 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs @@ -0,0 +1,12 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// How an went. A channel reads every value it does not know as . + public enum CallTransferOutcome + { + /// The line did not take the call; the caller is still on this call. + NotTaken = 0, + + /// The line took the call; this side hangs its leg up. + Taken = 1, + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs b/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs new file mode 100644 index 00000000..56a4c45f --- /dev/null +++ b/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs @@ -0,0 +1,17 @@ +namespace AgentCore.Application.Conversation.Actions +{ + /// + /// A host that moves a call to another line itself, such as through its phone system's API. A phone channel that can + /// transfer (GPT-Live today) hands a registered one each in place of its own transfer, + /// and takes its answer as the outcome: the vendor's own transfer may report none (OpenAI's SIP refer, probe T1). + /// Register it as a singleton: a call outlives the request that started it. + /// + public interface ICallTransfer + { + /// Moves the call. It runs once the caller heard the answer that asked for it. + /// The call and its target. + /// Cancels the move. + /// Whether the line took the call. + Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken); + } +} diff --git a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs b/src/AgentCore.Application/Conversation/Actions/TransferAction.cs index 37145e4f..f7f47563 100644 --- a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs +++ b/src/AgentCore.Application/Conversation/Actions/TransferAction.cs @@ -23,5 +23,13 @@ public TransferAction(Uri target) /// Gets the target. It is a URI and not a phone number, so a PSTN number and a SIP endpoint both fit. public Uri Target { get; } + + /// + /// Gets the instruction the voice follows when the line did not take the call, after which the call goes on. The + /// host writes it, because AgentCore writes no text for the model. Without it a failed transfer hangs up. A voice + /// may keep it for the rest of the call (GPT-Live appends it to its session instructions, as it does the + /// greeting), so word it as one past event, such as "the transfer you just started did not go through". + /// + public string? IfFailed { get; init; } } } diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index b63eddc1..0cb2fd6a 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -3161,3 +3161,25 @@ virtual AgentCore.Application.Conversation.Actions.ConversationAction.PrintMembe const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyName = "telnyx-relay-key" -> string! const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyVariable = "TELNYX_RELAY_KEY" -> string! static readonly AgentCore.Application.Secrets.KnownSecrets.TelnyxRelay -> AgentCore.Application.Secrets.SecretName! +AgentCore.Application.Conversation.Actions.TransferAction.IfFailed.get -> string? +AgentCore.Application.Conversation.Actions.TransferAction.IfFailed.init -> void +AgentCore.Application.Conversation.Actions.CallTransfer +AgentCore.Application.Conversation.Actions.CallTransfer.$() -> AgentCore.Application.Conversation.Actions.CallTransfer! +AgentCore.Application.Conversation.Actions.CallTransfer.CallTransfer() -> void +AgentCore.Application.Conversation.Actions.CallTransfer.ConversationId.get -> string! +AgentCore.Application.Conversation.Actions.CallTransfer.ConversationId.init -> void +AgentCore.Application.Conversation.Actions.CallTransfer.Equals(AgentCore.Application.Conversation.Actions.CallTransfer? other) -> bool +AgentCore.Application.Conversation.Actions.CallTransfer.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Conversation.Actions.CallTransfer.Headers.init -> void +AgentCore.Application.Conversation.Actions.CallTransferOutcome +AgentCore.Application.Conversation.Actions.CallTransferOutcome.NotTaken = 0 -> AgentCore.Application.Conversation.Actions.CallTransferOutcome +AgentCore.Application.Conversation.Actions.CallTransferOutcome.Taken = 1 -> AgentCore.Application.Conversation.Actions.CallTransferOutcome +AgentCore.Application.Conversation.Actions.CallTransfer.Target.get -> System.Uri! +AgentCore.Application.Conversation.Actions.CallTransfer.Target.init -> void +AgentCore.Application.Conversation.Actions.ICallTransfer +AgentCore.Application.Conversation.Actions.ICallTransfer.TransferAsync(AgentCore.Application.Conversation.Actions.CallTransfer! transfer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override AgentCore.Application.Conversation.Actions.CallTransfer.Equals(object? obj) -> bool +override AgentCore.Application.Conversation.Actions.CallTransfer.GetHashCode() -> int +override AgentCore.Application.Conversation.Actions.CallTransfer.ToString() -> string! +static AgentCore.Application.Conversation.Actions.CallTransfer.operator ==(AgentCore.Application.Conversation.Actions.CallTransfer? left, AgentCore.Application.Conversation.Actions.CallTransfer? right) -> bool +static AgentCore.Application.Conversation.Actions.CallTransfer.operator !=(AgentCore.Application.Conversation.Actions.CallTransfer? left, AgentCore.Application.Conversation.Actions.CallTransfer? right) -> bool diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs new file mode 100644 index 00000000..0d9f8c49 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs @@ -0,0 +1,15 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// How one GPT-Live call hands the caller to another line, and how it learns whether the line took the call. It runs + /// once the caller heard the answer that asked for the transfer, and never throws for a line that did not take it. + /// + internal interface ILiveTransferLine + { + /// The line. + /// Waits for the peer to close the AI leg, for a line whose only sign of success is that close. + /// The read loop's receive still open, or . + /// Cancels the hand-over. + Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken); + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs index 9066c67d..9e39ba31 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs @@ -12,10 +12,13 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call /// Where a late ack is logged. internal sealed class LiveCallEnd(TimeProvider time, string callId, ILogger logger) : IDisposable { - private readonly TaskCompletionSource<(ConversationEndReason Reason, string Cause)> _endNow = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly Lock _gate = new(); + private TaskCompletionSource<(ConversationEndReason Reason, string Cause)> _endNow = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // An end asked for while the loop acts on an earlier one, such as during a transfer that may fail and go on. + private (ConversationEndReason Reason, string Cause)? _endAfter; + private string? _endAfterAck; private ITimer? _ackTimer; @@ -27,7 +30,16 @@ internal sealed class LiveCallEnd(TimeProvider time, string callId, ILogger logg private int _finished; /// Gets the end asked for, once one is. - internal Task<(ConversationEndReason Reason, string Cause)> Requested => _endNow.Task; + internal Task<(ConversationEndReason Reason, string Cause)> Requested + { + get + { + lock (_gate) + { + return _endNow.Task; + } + } + } /// Gets whether the call finished: nothing goes out on its socket any more. internal bool Finished => Volatile.Read(ref _finished) == 1; @@ -47,7 +59,38 @@ public void Dispose() /// when it already was. internal bool TryFinish() => Interlocked.Exchange(ref _finished, 1) == 0; - internal void Now(ConversationEndReason reason, string cause) => _endNow.TrySetResult((reason, cause)); + internal void Now(ConversationEndReason reason, string cause) + { + lock (_gate) + { + if (!_endNow.TrySetResult((reason, cause))) + { + _endAfter ??= (reason, cause); + } + } + } + + /// + /// Listens for the next end, once the loop chose to go on after the one it was given. An end asked for in the + /// meantime is the next end. + /// + internal void Rearm() + { + lock (_gate) + { + _quietTimer?.Dispose(); + _quietTimer = null; + _ackTimer?.Dispose(); + _ackTimer = null; + _endAfterAck = null; + _endNow = new TaskCompletionSource<(ConversationEndReason Reason, string Cause)>(TaskCreationOptions.RunContinuationsAsynchronously); + if (_endAfter is { } next) + { + _endAfter = null; + _ = _endNow.TrySetResult(next); + } + } + } /// GPT-Live is still speaking: a quiet wait already running starts again. internal void AgentSpoke() diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallFinish.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallFinish.cs new file mode 100644 index 00000000..04fa13ac --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallFinish.cs @@ -0,0 +1,60 @@ +using AgentCore.AspNetCore.Calls; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// Ends one GPT-Live call exactly once: hangs the line up from this side when asked, ends the call core, waits for + /// the answers in flight, and closes the conversation. + /// + internal sealed class LiveCallFinish(PhoneCall call, LiveCallEnd end, LiveAnswers answers, Func> hangUp, ILogger logger) + { + private int _hungUp; + + internal async Task HangUpAndFinishAsync(ConversationEndReason reason, string cause, CancellationToken cancellationToken) + { + if (!end.Finished && Interlocked.Exchange(ref _hungUp, 1) == 0) + { + bool hungUp; + try + { + hungUp = await hangUp(cancellationToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.HangupFaulted(logger, call.CallId, fault); + hungUp = true; + } + + if (!hungUp) + { + OpenAiLiveLog.HangupFailed(logger, call.CallId); + } + } + + await FinishAsync(reason, cause).ConfigureAwait(false); + } + + internal async Task FinishAsync(ConversationEndReason reason, string cause) + { + if (!end.TryFinish()) + { + return; + } + + await call.EndAsync(reason, cause).ConfigureAwait(false); + await answers.WhenAnsweredAsync().ConfigureAwait(false); + + try + { + await call.CloseAsync().ConfigureAwait(false); + } + // The close takes no token, so a cancellation here is the store's own timeout. + catch (Exception fault) when (fault is not OutOfMemoryException) + { + OpenAiLiveLog.CloseFaulted(logger, call.CallId, fault); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandover.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandover.cs new file mode 100644 index 00000000..e52d7473 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandover.cs @@ -0,0 +1,8 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// How a hand-over went. + /// Whether the line took the call, and whether the AI leg is closed already. + /// The read loop's receive still open, or once the sideband closed. + /// Why the line did not take the call, for the log. + internal sealed record LiveHandover(LiveHandoverOutcome Outcome, Task? Receiving, string? Why = null); +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandoverOutcome.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandoverOutcome.cs new file mode 100644 index 00000000..d962272e --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHandoverOutcome.cs @@ -0,0 +1,15 @@ +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// Whether a line took a GPT-Live call. + internal enum LiveHandoverOutcome + { + /// The line did not take it; the caller is still on the AI leg. + NotTaken = 0, + + /// The line took it, and the AI leg is still up for this side to hang up. + Taken = 1, + + /// The line took it, and the peer already closed the AI leg. + TakenAndClosed = 2, + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs new file mode 100644 index 00000000..36d28453 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs @@ -0,0 +1,32 @@ +using AgentCore.Application.Conversation.Actions; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// The host's own transfer, through its . Its answer is the outcome, so nothing waits for + /// the peer's close, and the AI leg is hung up from this side once the line took the call. + /// + internal sealed class LiveHostTransfer( + ICallTransfer host, string conversationId, IReadOnlyDictionary headers, ILogger logger, string callId) : ILiveTransferLine + { + public async Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken) + { + CallTransferOutcome outcome; + try + { + outcome = await host.TransferAsync( + new CallTransfer { ConversationId = conversationId, Target = target, Headers = headers }, cancellationToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + OpenAiLiveLog.ReferFaulted(logger, callId, fault); + outcome = CallTransferOutcome.NotTaken; + } + + return outcome == CallTransferOutcome.Taken + ? new LiveHandover(LiveHandoverOutcome.Taken, receiving) + : new LiveHandover(LiveHandoverOutcome.NotTaken, receiving, "the host's line did not take the call"); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LivePeerClose.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LivePeerClose.cs new file mode 100644 index 00000000..bc98da97 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LivePeerClose.cs @@ -0,0 +1,49 @@ +using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// Waits for the peer to hang the AI leg up after a refer, the only sign the line took the call (see + /// ). The caller's words are still heard meanwhile, but start no turn. + /// + internal sealed class LivePeerClose(ILiveSideband sideband, LiveHearing hearing, TimeProvider time) + { + /// Reads the sideband until the peer closes it or runs out. + /// Whether the peer closed it, and the receive still open when it did not. + internal async Task<(bool Closed, Task? Receiving)> WaitAsync(TimeSpan wait, Task? receiving, CancellationToken cancellationToken) + { + using CancellationTokenSource waitStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task waited = Task.Delay(wait, time, waitStop.Token); + try + { + while (true) + { + receiving ??= sideband.ReceiveAsync(cancellationToken).AsTask(); + if (await Task.WhenAny(receiving, waited).ConfigureAwait(false) == waited) + { + await hearing.FlushAsync().ConfigureAwait(false); + return (false, receiving); + } + + string? json = await receiving.ConfigureAwait(false); + receiving = null; + LiveEvent? liveEvent = json is null ? null : LiveEventReader.Read(json); + + if (liveEvent is LiveEvent.Transcript delta) + { + await hearing.HearAsync(delta).ConfigureAwait(false); + } + else if (json is null || liveEvent is LiveEvent.Closed) + { + await hearing.FlushAsync().ConfigureAwait(false); + return (true, null); + } + } + } + finally + { + await waitStop.CancelAsync().ConfigureAwait(false); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs new file mode 100644 index 00000000..2edc608c --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call +{ + /// + /// The vendor's own transfer: a SIP refer through OpenAI. OpenAI never reports how a REFER went (probe T1, + /// docs/probes/live-transfer-t1): its answer is 200 even when the peer declines. So the peer's close of the AI leg + /// within is the transfer, and a call still up after it is a failed one. + /// + internal sealed class LiveReferTransfer(Func> refer, TimeSpan wait, ILogger logger, string callId) : ILiveTransferLine + { + public async Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken) + { + bool sent; + try + { + sent = await refer(target, cancellationToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + OpenAiLiveLog.ReferFaulted(logger, callId, fault); + sent = false; + } + + if (!sent) + { + return new LiveHandover(LiveHandoverOutcome.NotTaken, receiving, "the refer was refused"); + } + + (bool closed, receiving) = await peer.WaitAsync(wait, receiving, cancellationToken).ConfigureAwait(false); + return closed + ? new LiveHandover(LiveHandoverOutcome.TakenAndClosed, null) + : new LiveHandover(LiveHandoverOutcome.NotTaken, receiving, "the call was still up when the transfer wait ran out"); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs index 11fc7a67..41975627 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs @@ -1,20 +1,21 @@ using AgentCore.Application.Conversation.Actions; using AgentCore.Application.Runtime.Session; -using Microsoft.Extensions.Logging; namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call { /// - /// GPT-Live's side of . A request only records the target: the call's read loop sends the - /// refer once the caller heard the answer, so the transfer never cuts a reply. + /// GPT-Live's side of . A request only records the action: the call's read loop hands the + /// caller over through once the caller heard the answer, so the transfer never cuts a reply. /// - internal sealed class LiveTransfer(Func> refer, TimeSpan wait, Func leaving, Action scheduled) : IConversationChannel + internal sealed class LiveTransfer(ILiveTransferLine line, Func leaving, Action scheduled) : IConversationChannel { - private Uri? _target; + private TransferAction? _pending; - internal Uri? Target => Volatile.Read(ref _target); + internal ILiveTransferLine Line => line; - internal TimeSpan Wait => wait; + internal Uri? Target => Volatile.Read(ref _pending)?.Target; + + internal string? IfFailed => Volatile.Read(ref _pending)?.IfFailed; public ConversationActionResult Request(ConversationAction action) { @@ -26,7 +27,7 @@ public ConversationActionResult Request(ConversationAction action) // A call that is leaving has no line left to hand over, and a second transfer would contradict the first, // which may already be on the line. - if (leaving() || Interlocked.CompareExchange(ref _target, transfer.Target, null) is not null) + if (leaving() || Interlocked.CompareExchange(ref _pending, transfer, null) is not null) { return ConversationActionResult.Ending; } @@ -36,18 +37,7 @@ public ConversationActionResult Request(ConversationAction action) return ConversationActionResult.Scheduled; } - /// A thrown refer counts as a refused one: either way the line never got the call. - internal async Task ReferAsync(Uri target, ILogger logger, string callId, CancellationToken cancellationToken) - { - try - { - return await refer(target, cancellationToken).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - OpenAiLiveLog.ReferFaulted(logger, callId, fault); - return false; - } - } + /// The line did not take the call and the call goes on, so the caller's next words start turns again. + internal void Failed() => Volatile.Write(ref _pending, null); } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index 7783b150..77dda4ee 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -45,8 +45,6 @@ internal sealed class OpenAiLiveCall : IDisposable private readonly ILiveSideband _sideband; - private readonly Func> _hangUp; - private readonly ILogger _logger; private readonly string? _greeting; @@ -59,6 +57,8 @@ internal sealed class OpenAiLiveCall : IDisposable private readonly LiveTransfer? _transfer; + private readonly LiveCallFinish _finish; + // Set off the loop (an answer, a timer) and acted on by the loop, which alone touches the ledger. private readonly LiveCallEnd _end; @@ -66,13 +66,11 @@ internal sealed class OpenAiLiveCall : IDisposable private int _looping; - private int _hungUp; - private long _eventIds; /// /// A greeting leaves GPT-Live silent until the caller speaks. A - /// refer leaves the call unable to transfer, so its conversation answers a transfer as not supported. + /// transfer line leaves the call unable to transfer, so its conversation answers a transfer as not supported. /// internal OpenAiLiveCall( PhoneCall call, @@ -80,20 +78,19 @@ internal OpenAiLiveCall( Func> hangUp, ILogger logger, string? greeting = null, - Func>? refer = null, - TimeSpan? transferWait = null) + ILiveTransferLine? transferLine = null) { _call = call; _sideband = sideband; - _hangUp = hangUp; _logger = logger; _greeting = greeting; _end = new LiveCallEnd(call.Host.Time, call.CallId, logger); _hearing = new LiveHearing(call); - _transfer = refer is null + _transfer = transferLine is null ? null - : new LiveTransfer(refer, transferWait ?? OpenAiLiveSettings.DefaultTransferWait, () => _end.Finished || call.HasEnded, () => _ = TransferAfterAnswersAsync()); + : new LiveTransfer(transferLine, () => _end.Finished || call.HasEnded, () => _ = TransferAfterAnswersAsync()); _answers = new LiveAnswers(call, _end, SendAsync, NextEventId, () => call.EngineEnded || _transfer?.Target is not null, logger); + _finish = new LiveCallFinish(call, _end, _answers, hangUp, logger); } internal string CallId => _call.CallId; @@ -143,12 +140,19 @@ internal async Task RunAsync(CancellationToken cancellationToken) // The quiet wait after the last answer ends in the transfer when one waits, in place of the hang-up. if (cause == AgentEndedCause && _transfer is { Target: { } target }) { - receiving = await TransferAsync(_transfer, target, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false); + (receiving, bool goesOn) = await TransferAsync( + _transfer, target, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false); + + if (goesOn) + { + continue; + } + return; } OpenAiLiveLog.HungUpBecause(_logger, CallId, cause); - await HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); + await _finish.HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); return; } @@ -166,7 +170,7 @@ internal async Task RunAsync(CancellationToken cancellationToken) } await _hearing.FlushAsync().ConfigureAwait(false); - await FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -176,7 +180,7 @@ internal async Task RunAsync(CancellationToken cancellationToken) { OpenAiLiveLog.SidebandFaulted(_logger, CallId, fault); await _hearing.FlushAsync().ConfigureAwait(false); - await FinishAsync(ConversationEndReason.Faulted, SidebandFailedCause).ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandFailedCause).ConfigureAwait(false); } finally { @@ -210,7 +214,7 @@ internal async Task EndAsync(ConversationEndReason reason, string cause, Cancell } } - await HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); + await _finish.HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false); } private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken cancellationToken) @@ -243,7 +247,7 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken case LiveEvent.Closed end: await _hearing.FlushAsync().ConfigureAwait(false); - await FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); return true; case LiveEvent.Failed failed: @@ -297,105 +301,51 @@ private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancel } } - // OpenAI never reports how a REFER went (probe T1, docs/probes/live-transfer-t1): the REST answer is 200 even - // when the peer declines, and no event follows. The peer hangs the AI leg up once the line took the call, so a - // close inside the wait is the transfer, and a call still up after it is a failed one. The caller's words are - // still heard meanwhile, but start no turn. Returns the receive still open, for the loop's cleanup. - private async Task?> TransferAsync(LiveTransfer transfer, Uri target, Task? receiving, CancellationToken cancellationToken) + private async Task<(Task? Receiving, bool GoesOn)> TransferAsync( + LiveTransfer transfer, Uri target, Task? receiving, CancellationToken cancellationToken) { OpenAiLiveLog.Transferring(_logger, CallId); - if (!await transfer.ReferAsync(target, _logger, CallId, cancellationToken).ConfigureAwait(false)) - { - OpenAiLiveLog.TransferFailed(_logger, CallId, "the refer was refused"); - await HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false); - return receiving; - } + LiveHandover handover = await transfer.Line + .HandOverAsync(target, new LivePeerClose(_sideband, _hearing, _call.Host.Time), receiving, cancellationToken).ConfigureAwait(false); - using CancellationTokenSource waitStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task wait = Task.Delay(transfer.Wait, _call.Host.Time, waitStop.Token); - try + switch (handover.Outcome) { - while (true) - { - receiving ??= _sideband.ReceiveAsync(cancellationToken).AsTask(); - if (await Task.WhenAny(receiving, wait).ConfigureAwait(false) == wait) - { - await _hearing.FlushAsync().ConfigureAwait(false); - - OpenAiLiveLog.TransferFailed(_logger, CallId, "the call was still up when the transfer wait ran out"); - - await HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false); - - return receiving; - } - - string? json = await receiving.ConfigureAwait(false); - receiving = null; - LiveEvent? liveEvent = json is null ? null : LiveEventReader.Read(json); - - if (liveEvent is LiveEvent.Transcript delta) - { - await _hearing.HearAsync(delta).ConfigureAwait(false); - } - else if (json is null || liveEvent is LiveEvent.Closed) - { - await _hearing.FlushAsync().ConfigureAwait(false); + case LiveHandoverOutcome.Taken: + await _finish.HangUpAndFinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause, cancellationToken).ConfigureAwait(false); + return (handover.Receiving, false); - await FinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause).ConfigureAwait(false); + case LiveHandoverOutcome.TakenAndClosed: + await _finish.FinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause).ConfigureAwait(false); + return (null, false); - return null; - } - } - } - finally - { - await waitStop.CancelAsync().ConfigureAwait(false); + case LiveHandoverOutcome.NotTaken: + default: + bool goesOn = await FailedTransferAsync(transfer, handover.Why ?? "the line did not take the call", cancellationToken).ConfigureAwait(false); + return (handover.Receiving, goesOn); } } - private async Task HangUpAndFinishAsync(ConversationEndReason reason, string cause, CancellationToken cancellationToken) + // With the host's words for a failed transfer, GPT-Live says them and the call goes on: the end that started the + // transfer is spent, so the loop listens for the next one. An engine end that came meanwhile hangs up after them. + private async Task FailedTransferAsync(LiveTransfer transfer, string why, CancellationToken cancellationToken) { - if (!_end.Finished && Interlocked.Exchange(ref _hungUp, 1) == 0) + if (transfer.IfFailed is not { } words || _call.HasEnded) { - bool hungUp; - try - { - hungUp = await _hangUp(cancellationToken).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OutOfMemoryException) - { - OpenAiLiveLog.HangupFaulted(_logger, CallId, fault); - hungUp = true; - } - - if (!hungUp) - { - OpenAiLiveLog.HangupFailed(_logger, CallId); - } + OpenAiLiveLog.TransferFailed(_logger, CallId, why); + await _finish.HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false); + return false; } - await FinishAsync(reason, cause).ConfigureAwait(false); - } - - private async Task FinishAsync(ConversationEndReason reason, string cause) - { - if (!_end.TryFinish()) + OpenAiLiveLog.TransferFailedGoingOn(_logger, CallId, why); + transfer.Failed(); + _end.Rearm(); + await SendAsync(OpenAiLiveWire.SayNow(NextEventId(), words), cancellationToken).ConfigureAwait(false); + if (_call.EngineEnded) { - return; + _end.WhenQuiet(); } - await _call.EndAsync(reason, cause).ConfigureAwait(false); - await _answers.WhenAnsweredAsync().ConfigureAwait(false); - - try - { - await _call.CloseAsync().ConfigureAwait(false); - } - // The close takes no token, so a cancellation here is the store's own timeout. - catch (Exception fault) when (fault is not OutOfMemoryException) - { - OpenAiLiveLog.CloseFaulted(_logger, CallId, fault); - } + return true; } private string NextEventId() => "ac" + Interlocked.Increment(ref _eventIds).ToString(CultureInfo.InvariantCulture); diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs index da80c352..e454b117 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using AgentCore.Application.Conversation.Actions; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; using AgentCore.Domain.Audit; @@ -25,6 +26,12 @@ internal sealed class OpenAiLiveCalls(IServiceProvider services) : IHostedServic private readonly CancellationTokenSource _stopping = new(); + /// + /// Gets the host's own transfer, or when it registered none. It comes from the root + /// services: a call outlives the request that started it, so a scoped one would be used after its scope ended. + /// + internal ICallTransfer? HostTransfer => services.GetService(); + private readonly Lock _gate = new(); private bool _stopped; diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs index a7f15b30..3e23c05d 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs @@ -51,7 +51,10 @@ internal static partial class OpenAiLiveLog [LoggerMessage(EventId = 325, Level = LogLevel.Warning, Message = "transferring the call {CallId} failed: {Why}. The call is hung up.")] internal static partial void TransferFailed(ILogger logger, string callId, string why); - [LoggerMessage(EventId = 326, Level = LogLevel.Error, Message = "sending the refer of the call {CallId} threw.")] + [LoggerMessage(EventId = 326, Level = LogLevel.Error, Message = "sending the transfer of the call {CallId} threw.")] internal static partial void ReferFaulted(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 327, Level = LogLevel.Warning, Message = "transferring the call {CallId} failed: {Why}. The caller is told, and the call goes on.")] + internal static partial void TransferFailedGoingOn(ILogger logger, string callId, string why); } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs index 0b2cc71c..4413285f 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -114,14 +114,17 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming return false; } + ILiveTransferLine transferLine = calls.HostTransfer is { } hostTransfer + ? new LiveHostTransfer(hostTransfer, call.ConversationId, incoming.Headers, logger, incoming.CallId) + : new LiveReferTransfer((target, token) => control.ReferAsync(incoming.CallId, target, token), settings.TransferWait, logger, incoming.CallId); + calls.Run(new OpenAiLiveCall( call, sideband, _ => control.HangupQuietlyAsync(incoming.CallId), logger, settings.Greeting, - (target, token) => control.ReferAsync(incoming.CallId, target, token), - settings.TransferWait)); + transferLine)); return true; } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs index 319e5ab4..2c8c29af 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs @@ -75,6 +75,9 @@ internal static Uri AttachUri(Uri apiBase, string callId) /// internal static JsonObject GreetFirst(string eventId, string greeting) => SessionFact(eventId, greeting); + /// Words GPT-Live says at once, outside every delegation: the event that makes it greet first. + internal static JsonObject SayNow(string eventId, string words) => SessionFact(eventId, words); + /// /// A fact for the whole session, under no delegation, from OpenAI's delegation guide: at most 500 tokens, such as /// "The current date is December 10, 2024. Today is Tuesday." diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs index 2623301e..1f165f92 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs @@ -1,6 +1,8 @@ using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Actions; using AgentCore.Application.Hooks; using AgentCore.AspNetCore.Calls; +using AgentCore.AspNetCore.Vendors.OpenAiLive; using AgentCore.AspNetCore.Vendors.OpenAiLive.Call; using AgentCore.TestSupport; using Microsoft.Extensions.AI; @@ -46,12 +48,13 @@ private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCa public TaskCompletionSource Referred { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// - /// Starts the call. With unset the call cannot transfer, as on a host that wired no - /// refer; set, each refer answers it. + /// Starts the call. With and unset the call cannot + /// transfer. transfers by refer, and each refer answers it; + /// transfers through a host's own , which answers it. /// public static async Task StartAsync( IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null, - string? greeting = Greeting, Func? tools = null, bool? refers = null) + string? greeting = Greeting, Func? tools = null, bool? refers = null, CallTransferOutcome? hostAnswers = null) { CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(Noon) { Zone = TimeZoneInfo.Utc }; @@ -59,8 +62,14 @@ public static async Task StartAsync( PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), ct)).Call!; await call.StartAsync(ct); RunningLiveCall running = new(harness, time, call, sideband ?? new FakeSideband()); - Func>? refer = refers is { } answer ? (target, _) => running.ReferAsync(target, answer) : null; - OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger ?? NullLogger.Instance, greeting, refer); + logger ??= NullLogger.Instance; + ILiveTransferLine? line = (hostAnswers, refers) switch + { + ({ } outcome, _) => new LiveHostTransfer(new AnsweringTransfer(running, outcome), call.ConversationId, new Dictionary(), logger, call.CallId), + (null, { } answer) => new LiveReferTransfer((target, _) => running.ReferAsync(target, answer), OpenAiLiveSettings.DefaultTransferWait, logger, call.CallId), + _ => null, + }; + OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger, greeting, line); running.Loop = live.RunAsync(ct); return running; } @@ -83,5 +92,14 @@ private Task HangUpAsync(CancellationToken cancellationToken) _ = HungUp.TrySetResult(Sideband.Pushed); return Task.FromResult(true); } + + private sealed class AnsweringTransfer(RunningLiveCall running, CallTransferOutcome outcome) : ICallTransfer + { + public Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken) + { + _ = running.Referred.TrySetResult(transfer.Target); + return Task.FromResult(outcome); + } + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs index dcc2fb6e..402acb69 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs @@ -25,6 +25,8 @@ public sealed class OpenAiLiveTransferTests { private static readonly Uri Staff = new("tel:+15550002222"); + private const string SayTheTransferFailed = "Tell the caller the transfer did not go through."; + private static CancellationToken Ct => TestContext.Current.CancellationToken; // GPT-Live's ack marks the start of speech, so time alone must not send the refer before the answer was heard. @@ -114,6 +116,40 @@ public async Task ADelegationWhileATransferWaitsStartsNoTurn() Assert.Single(hook.Of()); } + // A host that moves the call itself knows at once whether the line took it: nothing waits for the peer's close. + [Fact(Timeout = 30_000)] + public async Task ATransferTheHostCarriedOutEndsTheCallAsTransferredAtOnce() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new GatedToolCallingChatClient(), [hook], tools: TransferTool(new()), hostAnswers: CallTransferOutcome.Taken); + + await ReferAsync(running); + _ = await running.HungUp.Task; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.Equal((ConversationEndReason.TransferredToHuman, OpenAiLiveCall.TransferredCause), (ended.Reason, ended.Call!.Cause)); + } + + // GPT-Live says the host's words under no delegation, as it says the greeting, and the caller can go on talking. + [Fact(Timeout = 30_000)] + public async Task ATransferTheHostCouldNotCarryOutSaysTheHostsWordsAndTheCallGoesOn() + { + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new GatedToolCallingChatClient(), [hook], tools: TransferTool(new(), SayTheTransferFailed), hostAnswers: CallTransferOutcome.NotTaken); + + await ReferAsync(running); + JsonObject said = await running.Sideband.WaitForSentAsync(sent => (string?)sent["content"] == SayTheTransferFailed); + running.Sideband.Push(Ask("item_2", "Okay, thanks.", 9000)); + _ = await running.Sideband.WaitForSentAsync(sent => IsCommentary(sent) && (string?)sent["delegation_id"] == "item_2"); + + Assert.Equal("session.instructions.append", (string?)said["type"]); + Assert.Null(said["delegation_id"]); + Assert.False(running.HungUp.Task.IsCompleted); + Assert.False(running.Call.HasEnded); + } + [Fact(Timeout = 30_000)] public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() { @@ -130,11 +166,11 @@ public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() } // Every tool of the harness's document asks for a transfer through the scope the binder fills, as a host's would. - private static Func TransferTool(ConcurrentQueue answers) => + private static Func TransferTool(ConcurrentQueue answers, string? ifFailed = null) => tool => AIFunctionFactory.Create( (ToolCallScope scope) => { - ConversationActionResult answer = scope.Conversation.Request(new TransferAction(Staff)); + ConversationActionResult answer = scope.Conversation.Request(new TransferAction(Staff) { IfFailed = ifFailed }); answers.Enqueue(answer); return answer.ToString(); },