diff --git a/README.md b/README.md index 20c76f8..f2c37b7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # Agent Framework C# Quick-Start +**Governed chat: Agentic Intent.** Every session gets a governed Instruction and every message a Preamble from a versioned policy. See [docs/governance/agentic-intent-adoption.md](docs/governance/agentic-intent-adoption.md) to replace the sample policy or add jurisdiction rules. + **Azure Bicep Infrastucture as Standalone Landing Zone** [![Landing Zone IaC](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-iac.yml/badge.svg)](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-iac.yml) diff --git a/data/Admin/Drop Tables.sql b/data/Admin/Drop Tables.sql index fed86f7..6462ba0 100644 --- a/data/Admin/Drop Tables.sql +++ b/data/Admin/Drop Tables.sql @@ -1,49 +1,155 @@ --- -- SELECT IS_SRVROLEMEMBER('sysadmin') AS IsSysAdmin, IS_SRVROLEMEMBER('dbcreator') AS IsDbCreator; --- -- Go +/* + Safe dynamic database cleanup script + Purpose: + Remove all user objects in the current database without dropping the database itself. + This removes: + - foreign keys + - other table constraints + - triggers + - views + - stored procedures + - functions + - tables -Use AgentFramework -GO + IMPORTANT: + Run this against the target database only. +*/ -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[__EFMigrationsHistory]') AND type in (N'U')) -DROP TABLE [dbo].[__EFMigrationsHistory] -GO +SET NOCOUNT ON; +SET XACT_ABORT ON; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[IntentEmbeddings]') AND type in (N'U')) -DROP TABLE [Chat].[IntentEmbeddings] -GO +DECLARE @sql NVARCHAR(MAX) = N''; +DECLARE @loopCount INT = 0; +DECLARE @maxLoops INT = 50; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[RequestIdempotency]') AND type in (N'U')) -DROP TABLE [Chat].[RequestIdempotency] -GO +------------------------------------------------------------ +-- 1) Drop all foreign keys (repeat until none remain) +------------------------------------------------------------ +WHILE EXISTS (SELECT 1 FROM sys.foreign_keys WHERE is_ms_shipped = 0) +BEGIN + SET @loopCount += 1; + IF @loopCount > @maxLoops + BEGIN + RAISERROR('Foreign key cleanup did not converge. Check for circular dependencies or blocked constraints.', 16, 1); + BREAK; + END; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[ChatGovernance]') AND type in (N'U')) -DROP TABLE [Chat].[ChatGovernance] -GO + SET @sql = N''; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[ChatMessages]') AND type in (N'U')) -DROP TABLE [Chat].[ChatMessages] -GO + SELECT @sql += + N'ALTER TABLE ' + QUOTENAME(SCHEMA_NAME(fk.schema_id)) + N'.' + QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + + N' DROP CONSTRAINT ' + QUOTENAME(fk.name) + N';' + CHAR(13) + CHAR(10) + FROM sys.foreign_keys AS fk + WHERE fk.is_ms_shipped = 0 + ORDER BY SCHEMA_NAME(fk.schema_id), OBJECT_NAME(fk.parent_object_id), fk.name; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[ChatSessions]') AND type in (N'U')) -DROP TABLE [Chat].[ChatSessions] -GO + IF LEN(@sql) > 0 + BEGIN + EXEC sys.sp_executesql @sql; + END; +END; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[Actors]') AND type in (N'U')) -DROP TABLE [Chat].[Actors] -GO +------------------------------------------------------------ +-- 2) Drop all other table constraints (PK, UNIQUE, CHECK, DEFAULT) +------------------------------------------------------------ +SET @sql = N''; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[PlaybookMaterializations]') AND type in (N'U')) -DROP TABLE [Chat].[PlaybookMaterializations] -GO +SELECT @sql += + N'ALTER TABLE ' + QUOTENAME(SCHEMA_NAME(t.schema_id)) + N'.' + QUOTENAME(t.name) + + N' DROP CONSTRAINT ' + QUOTENAME(c.name) + N';' + CHAR(13) + CHAR(10) +FROM sys.objects AS c +INNER JOIN sys.tables AS t + ON c.parent_object_id = t.object_id +WHERE c.type IN ('PK', 'UQ', 'C', 'D', 'F') + AND c.is_ms_shipped = 0 + AND t.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(t.schema_id), t.name, c.name; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[PlaybookExecutions]') AND type in (N'U')) -DROP TABLE [Chat].[PlaybookExecutions] -GO +IF LEN(@sql) > 0 +BEGIN + EXEC sys.sp_executesql @sql; +END; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[PlaybookSteps]') AND type in (N'U')) -DROP TABLE [Chat].[PlaybookSteps] -GO +------------------------------------------------------------ +-- 3) Drop triggers +------------------------------------------------------------ +SET @sql = N''; -IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[Chat].[Playbooks]') AND type in (N'U')) -DROP TABLE [Chat].[Playbooks] -GO \ No newline at end of file +SELECT @sql += + N'DROP TRIGGER ' + QUOTENAME(SCHEMA_NAME(o.schema_id)) + N'.' + QUOTENAME(o.name) + N';' + CHAR(13) + CHAR(10) +FROM sys.objects AS o +WHERE o.type = 'TR' + AND o.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(o.schema_id), o.name; + +IF LEN(@sql) > 0 +BEGIN + EXEC sys.sp_executesql @sql; +END; + +------------------------------------------------------------ +-- 4) Drop views +------------------------------------------------------------ +SET @sql = N''; + +SELECT @sql += + N'DROP VIEW ' + QUOTENAME(SCHEMA_NAME(v.schema_id)) + N'.' + QUOTENAME(v.name) + N';' + CHAR(13) + CHAR(10) +FROM sys.views AS v +WHERE v.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(v.schema_id), v.name; + +IF LEN(@sql) > 0 +BEGIN + EXEC sys.sp_executesql @sql; +END; + +------------------------------------------------------------ +-- 5) Drop stored procedures +------------------------------------------------------------ +SET @sql = N''; + +SELECT @sql += + N'DROP PROCEDURE ' + QUOTENAME(SCHEMA_NAME(p.schema_id)) + N'.' + QUOTENAME(p.name) + N';' + CHAR(13) + CHAR(10) +FROM sys.procedures AS p +WHERE p.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(p.schema_id), p.name; + +IF LEN(@sql) > 0 +BEGIN + EXEC sys.sp_executesql @sql; +END; + +------------------------------------------------------------ +-- 6) Drop functions +------------------------------------------------------------ +SET @sql = N''; + +SELECT @sql += + N'DROP FUNCTION ' + QUOTENAME(SCHEMA_NAME(f.schema_id)) + N'.' + QUOTENAME(f.name) + N';' + CHAR(13) + CHAR(10) +FROM sys.objects AS f +WHERE f.type IN ('FN', 'IF', 'TF', 'FS', 'FT') + AND f.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(f.schema_id), f.name; + +IF LEN(@sql) > 0 +BEGIN + EXEC sys.sp_executesql @sql; +END; + +------------------------------------------------------------ +-- 7) Drop tables +------------------------------------------------------------ +SET @sql = N''; + +SELECT @sql += + N'DROP TABLE ' + QUOTENAME(SCHEMA_NAME(t.schema_id)) + N'.' + QUOTENAME(t.name) + N';' + CHAR(13) + CHAR(10) +FROM sys.tables AS t +WHERE t.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(t.schema_id), t.name; + +IF LEN(@sql) > 0 +BEGIN + EXEC sys.sp_executesql @sql; +END; + +PRINT 'Cleanup complete: all user-defined objects were dropped, database left intact.'; \ No newline at end of file diff --git a/docs/governance/agentic-intent-adoption.md b/docs/governance/agentic-intent-adoption.md new file mode 100644 index 0000000..ce7d9ee --- /dev/null +++ b/docs/governance/agentic-intent-adoption.md @@ -0,0 +1,28 @@ +# Agentic Intent: Instruction and Preamble + +Agentic Intent is the first governance **policy**, applied beside the four pillars (Observability, Repeatability, Auditability, Defensibility). Principles are `Boundary`, `Value` and `Conduct`, with precedence Boundary > Value > Conduct > persona > user request. + +## How the template applies it + +| Concept | Cadence | Where | +|---|---|---| +| Instruction | Once per chat session | `ChatMessageIntentRouter` opens `ChatGovernanceScope.Begin(tenantId, instruction)` with the persona plus governed directives | +| Preamble | Before every user message | `GovernedChatClient` (via `UseGovernedIntent`) in `ConfigureServices.cs`; never rendered as a chat bubble | +| Policy identity | Every governed inference | `ChatGovernanceGate.Enforce` adds `policy://agentic-intent/{id}/{version}/{hash}` to `Defensibility.PoliciesApplied`; playbook steps record the same reference | + +A change to the policy text or version changes the hash and is reported as drift. + +## Configuration + +```json +"GovernedIntent": { "IntentRequired": true, "PreambleMode": "Compact" } +``` + +- `IntentRequired`: fail the call when no policy resolves. +- `PreambleMode`: `Compact` (principle ids) or `Full` (statements). + +## Replacing the sample policy + +`Infrastructure.AgentFramework/Governance/SampleIntentPolicy.cs` is a small, tenant-agnostic sample. Replace the `IIntentPolicyProvider` registration in `ConfigureServices.cs` with your own provider (for example one that resolves a versioned policy per tenant and jurisdiction from storage). Do not hard-code legal text in the template; supply it as versioned policy. + +Prompting is guidance, not enforcement. Register the governed client as the default `IChatClient` so every call goes through it. diff --git a/src/Core.Application/Core.Application.csproj b/src/Core.Application/Core.Application.csproj index b303c42..c3c576b 100644 --- a/src/Core.Application/Core.Application.csproj +++ b/src/Core.Application/Core.Application.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Core.Application/Governance/ChatGovernanceGate.cs b/src/Core.Application/Governance/ChatGovernanceGate.cs index 4dc30ad..1dd76c7 100644 --- a/src/Core.Application/Governance/ChatGovernanceGate.cs +++ b/src/Core.Application/Governance/ChatGovernanceGate.cs @@ -10,12 +10,19 @@ public sealed class ChatGovernanceGate public Goodtocode.Agents.Governance.Application.GovernedEvaluationResult Enforce( IRlsContext userContext, - string prompt) + string prompt, + IntentPolicy? policy = null) { ArgumentNullException.ThrowIfNull(userContext); ArgumentException.ThrowIfNullOrWhiteSpace(prompt); var correlationId = Guid.NewGuid(); + var policiesApplied = new List { GovernanceReference.Parse("policy://chat/governance-v1") }; + if (policy is not null) + { + policiesApplied.Add(GovernanceReference.Parse($"policy://agentic-intent/{policy.PolicyId}/{policy.Version}/{policy.ComputeLockHash()}")); + } + return enforcer.Enforce(new GovernanceEvaluationRequest { Governance = new EvaluationGovernanceRecord @@ -45,7 +52,7 @@ public Goodtocode.Agents.Governance.Application.GovernedEvaluationResult Enforce }, Defensibility = new DefensibilityRecord { - PoliciesApplied = [GovernanceReference.Parse("policy://chat/governance-v1")], + PoliciesApplied = policiesApplied, JustificationRefs = [ GovernanceReference.Parse("justification://chat/governance-v1") @@ -54,7 +61,7 @@ public Goodtocode.Agents.Governance.Application.GovernedEvaluationResult Enforce ConfidenceScore = null } }, - ExistingSystemInstruction = "You are a helpful AI assistant.", + Instruction = AgentInstruction.Create("You are a helpful AI assistant.", InstructionScope.Session), RepeatabilityPromptContent = prompt, RepeatabilityInputs = new Dictionary { diff --git a/src/Core.Application/Playbooks/Governance/SharedPlaybookGovernanceActivityRecorder.cs b/src/Core.Application/Playbooks/Governance/SharedPlaybookGovernanceActivityRecorder.cs index 9d5249d..55066a2 100644 --- a/src/Core.Application/Playbooks/Governance/SharedPlaybookGovernanceActivityRecorder.cs +++ b/src/Core.Application/Playbooks/Governance/SharedPlaybookGovernanceActivityRecorder.cs @@ -32,18 +32,25 @@ public class SharedPlaybookGovernanceActivityRecorder public IReadOnlyList Records => _records; - public Task OnCollectedAsync(PlaybookIdentity identity, TEvidence evidence, string? toolName, CancellationToken cancellationToken = default) => - RecordStageAsync(identity, "collect", toolName, summarySelector.SummarizeEvidence(evidence), evidence); + public Task OnCollectedAsync(PlaybookIdentity identity, TEvidence evidence, string? toolName, IReadOnlyList governedTurns, CancellationToken cancellationToken = default) => + RecordStageAsync(identity, "collect", toolName, summarySelector.SummarizeEvidence(evidence), evidence, governedTurns); - public Task OnEvaluatedAsync(PlaybookIdentity identity, TFinding finding, string? toolName, CancellationToken cancellationToken = default) => - RecordStageAsync(identity, "evaluate", toolName, summarySelector.SummarizeFinding(finding), finding); + public Task OnEvaluatedAsync(PlaybookIdentity identity, TFinding finding, string? toolName, IReadOnlyList governedTurns, CancellationToken cancellationToken = default) => + RecordStageAsync(identity, "evaluate", toolName, summarySelector.SummarizeFinding(finding), finding, governedTurns); - public Task OnRecordedAsync(PlaybookIdentity identity, TMaterialization materialization, string? toolName, CancellationToken cancellationToken = default) => - RecordStageAsync(identity, "record", toolName, summarySelector.SummarizeMaterialization(materialization), materialization); + public Task OnRecordedAsync(PlaybookIdentity identity, TMaterialization materialization, string? toolName, IReadOnlyList governedTurns, CancellationToken cancellationToken = default) => + RecordStageAsync(identity, "record", toolName, summarySelector.SummarizeMaterialization(materialization), materialization, governedTurns); - private Task RecordStageAsync(PlaybookIdentity identity, string stage, string? toolName, string promptContent, object? rawInput) + private Task RecordStageAsync(PlaybookIdentity identity, string stage, string? toolName, string promptContent, object? rawInput, IReadOnlyList governedTurns) { var correlationId = Guid.NewGuid(); + var intent = governedTurns.FirstOrDefault(x => x.PolicyId is not null); + var policies = new List { GovernanceReference.Parse($"policy://playbook/{identity.PlaybookKey}-v1") }; + if (intent is not null) + { + policies.Add(GovernanceReference.Parse($"policy://agentic-intent/{intent.PolicyId}/{intent.PolicyVersion}/{intent.PolicyHash}")); + } + var record = new EvaluationGovernanceRecord { PolicyProfileVersion = $"playbook-{identity.PlaybookKey}-{identity.Version}", @@ -70,7 +77,7 @@ private Task RecordStageAsync(PlaybookIdentity identity, string stage, string? t }, Defensibility = new DefensibilityRecord { - PoliciesApplied = [GovernanceReference.Parse($"policy://playbook/{identity.PlaybookKey}-v1")], + PoliciesApplied = policies, JustificationRefs = [GovernanceReference.Parse($"justification://playbook/{identity.PlaybookKey}-{stage}")], ReasoningSummary = $"Playbook '{identity.PlaybookKey}' '{stage}' stage requires an attributable, traceable, and repeatable governance envelope.", ConfidenceScore = null diff --git a/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs b/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs index 4e213ac..46c62f3 100644 --- a/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs +++ b/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs @@ -5,6 +5,9 @@ using Goodtocode.AgentFramework.Core.Application.Governance; using Goodtocode.AgentFramework.Core.Domain.Governance; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Governance; +using Goodtocode.Agents.Governance.Application; +using Goodtocode.Agents.Governance.Domain; using Goodtocode.Mediator; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -26,8 +29,14 @@ public sealed class ChatMessageIntentRouter( IWebSearchProvider webSearchProvider, IIntentClassifier intentClassifier, Tools.IAgentChatContextAccessor chatContextAccessor, + IIntentPolicyProvider intentPolicyProvider, + ChatGovernanceScope governanceScope, + IAgentInstructionsComposer instructionsComposer, ILogger logger) : IChatMessageRouter, IIntentRouter { + private readonly IIntentPolicyProvider _intentPolicyProvider = intentPolicyProvider; + private readonly ChatGovernanceScope _governanceScope = governanceScope; + private readonly IAgentInstructionsComposer _instructionsComposer = instructionsComposer; private readonly AIAgent _agent = agent; private readonly ISender _sender = sender; private readonly IAgentFrameworkContext _context = context; @@ -67,6 +76,11 @@ public async Task ResolveReplyAsync( _chatContextAccessor.SetCurrentChatSession(chatSessionId); try { + var governed = await GovernTurnAsync(chatSessionId, message, cancellationToken); + var instruction = AgentInstruction.Create( + $"{governed.PromptContext.SystemInstruction}\n\n{_instructionsComposer.Compose()}", + InstructionScope.Session); + using var governedScope = _governanceScope.Begin(_rlsContext.TenantId, instruction); var chatHistory = await BuildChatHistoryAsync(chatSessionId, message, cancellationToken); if (mode == ChatRoutingMode.Routed) @@ -151,22 +165,30 @@ private async Task GuardReplyAsync( } } - private async Task> BuildChatHistoryAsync( + private async Task GovernTurnAsync( Guid chatSessionId, string userMessage, CancellationToken cancellationToken) { - var chatSession = await _sender.Send(new GetMyChatSessionQuery { Id = chatSessionId }, cancellationToken); - var governed = _governanceGate.Enforce(_rlsContext, userMessage); + var policy = await _intentPolicyProvider.ResolveAsync(new IntentPolicyRequest(_rlsContext.TenantId), cancellationToken); + var governed = _governanceGate.Enforce(_rlsContext, userMessage, policy); _context.Set().Add(_governanceGate.CreatePersistenceRecord( _rlsContext.OwnerId, _rlsContext.TenantId, chatSessionId, governed)); await _context.SaveChangesAsync(cancellationToken); + return governed; + } + + private async Task> BuildChatHistoryAsync( + Guid chatSessionId, + string userMessage, + CancellationToken cancellationToken) + { + var chatSession = await _sender.Send(new GetMyChatSessionQuery { Id = chatSessionId }, cancellationToken); var chatHistory = new List { - new(ChatRole.System, governed.PromptContext.SystemInstruction), new(ChatRole.System, ToolRoutingInstructions.NoFutureTensePreamble), BuildSelectionContextMessage(chatSessionId) }; diff --git a/src/Infrastructure.AgentFramework/ConfigureServices.cs b/src/Infrastructure.AgentFramework/ConfigureServices.cs index 30ea910..5312e80 100644 --- a/src/Infrastructure.AgentFramework/ConfigureServices.cs +++ b/src/Infrastructure.AgentFramework/ConfigureServices.cs @@ -13,6 +13,9 @@ using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; using Goodtocode.AgentFramework.Core.Application.Chats.Journeys; using Goodtocode.AgentFramework.Core.Application.Governance; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Governance; +using Goodtocode.Agents.Governance.Application; +using Goodtocode.Agents.Playbook.Prompting.Governance; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -84,6 +87,15 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo .ValidateOnStart(); services.AddSingleton(); + services.AddOptions() + .Bind(configuration.GetSection(GovernedIntentOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(new StaticIntentPolicyProvider(SampleIntentPolicy.Create())); + services.AddGovernedIntentPreamble(); + services.AddScoped(); var searchProviderOptions = configuration.GetSection(SearchProviderOptions.SectionName).Get() @@ -203,7 +215,7 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo { var chatClient = provider.GetRequiredService(); var loggerFactory = provider.GetRequiredService(); - var instructionsComposer = provider.GetRequiredService(); + var governedOptions = provider.GetRequiredService>().Value; var searchOptions = provider.GetRequiredService>().Value; var tools = new List { @@ -223,14 +235,19 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo Description = "GoodToCode AgentFramework Copilot", ChatOptions = new Microsoft.Extensions.AI.ChatOptions { - Instructions = instructionsComposer.Compose(), Tools = tools } }; return chatClient.AsAIAgent( options: agentOptions, - clientFactory: null, + clientFactory: inner => new ChatClientBuilder(inner) + .UseGovernedIntent(new GovernedChatClientOptions + { + IntentRequired = governedOptions.IntentRequired, + PreambleMode = governedOptions.PreambleMode + }) + .Build(provider), loggerFactory: loggerFactory, services: provider ); diff --git a/src/Infrastructure.AgentFramework/Execution/PlaybookWorkflowGraphExecutor.cs b/src/Infrastructure.AgentFramework/Execution/PlaybookWorkflowGraphExecutor.cs index bdfc260..692098b 100644 --- a/src/Infrastructure.AgentFramework/Execution/PlaybookWorkflowGraphExecutor.cs +++ b/src/Infrastructure.AgentFramework/Execution/PlaybookWorkflowGraphExecutor.cs @@ -21,7 +21,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; /// graph is bypassed in favor of direct, in-order tool calls with the same skip semantics as the /// package's own PlaybookExecutor. /// -public sealed class PlaybookWorkflowGraphExecutor(IRepeatPolicy repeatPolicy) +public sealed class PlaybookWorkflowGraphExecutor(IRepeatPolicy repeatPolicy, GovernedTurnLedger? turns = null) { public async Task> ExecuteAsync( PlaybookIdentity identity, @@ -45,8 +45,8 @@ public async Task // The graph only models a full run; any skipped stage uses the same skip semantics as PlaybookExecutor. return plan.Run == ActionType.All - ? await ExecuteRerunAsync(identity, collectTool, evaluateTool, recordTool, input, activityRecorder, startedUtc, cancellationToken).ConfigureAwait(false) - : await ExecuteSkippingAsync(identity, collectTool, evaluateTool, recordTool, input, request, plan, activityRecorder, startedUtc, cancellationToken).ConfigureAwait(false); + ? await ExecuteRerunAsync(identity, collectTool, evaluateTool, recordTool, input, activityRecorder, turns, startedUtc, cancellationToken).ConfigureAwait(false) + : await ExecuteSkippingAsync(identity, collectTool, evaluateTool, recordTool, input, request, plan, activityRecorder, turns, startedUtc, cancellationToken).ConfigureAwait(false); } /// @@ -60,12 +60,13 @@ private async Task recordTool, TCollectInput input, IPlaybookStepActivityRecorder? activityRecorder, + GovernedTurnLedger? turns, DateTimeOffset startedUtc, CancellationToken cancellationToken) { - var collectNode = new CollectNode(identity, collectTool, activityRecorder, cancellationToken); - var evaluateNode = new EvaluateNode(identity, evaluateTool, activityRecorder, cancellationToken); - var recordNode = new RecordNode(identity, recordTool, activityRecorder, cancellationToken); + var collectNode = new CollectNode(identity, collectTool, activityRecorder, turns, cancellationToken); + var evaluateNode = new EvaluateNode(identity, evaluateTool, activityRecorder, turns, cancellationToken); + var recordNode = new RecordNode(identity, recordTool, activityRecorder, turns, cancellationToken); var builder = new WorkflowBuilder(collectNode); builder.AddEdge(collectNode, evaluateNode); @@ -112,6 +113,7 @@ private static async Task request, RepeatPlan plan, IPlaybookStepActivityRecorder? activityRecorder, + GovernedTurnLedger? turns, DateTimeOffset startedUtc, CancellationToken cancellationToken) { @@ -125,7 +127,7 @@ private static async Task Drain(GovernedTurnLedger? turns) => turns?.Drain() ?? []; + private sealed class CollectNode( PlaybookIdentity identity, ICollectStepTool tool, IPlaybookStepActivityRecorder? activityRecorder, + GovernedTurnLedger? turns, CancellationToken cancellationToken) : Executor("collect") { @@ -179,7 +184,7 @@ public override async ValueTask HandleAsync(TCollectInput message, IW if (activityRecorder is not null) { - await activityRecorder.OnCollectedAsync(identity, evidence, tool.ToolName, cancellationToken).ConfigureAwait(false); + await activityRecorder.OnCollectedAsync(identity, evidence, tool.ToolName, Drain(turns), cancellationToken).ConfigureAwait(false); } return evidence; @@ -190,6 +195,7 @@ private sealed class EvaluateNode( PlaybookIdentity identity, IEvaluateStepTool tool, IPlaybookStepActivityRecorder? activityRecorder, + GovernedTurnLedger? turns, CancellationToken cancellationToken) : Executor("evaluate") { @@ -202,7 +208,7 @@ public override async ValueTask HandleAsync(TEvidence message, IWorkfl if (activityRecorder is not null) { - await activityRecorder.OnEvaluatedAsync(identity, finding, tool.ToolName, cancellationToken).ConfigureAwait(false); + await activityRecorder.OnEvaluatedAsync(identity, finding, tool.ToolName, Drain(turns), cancellationToken).ConfigureAwait(false); } return finding; @@ -213,6 +219,7 @@ private sealed class RecordNode( PlaybookIdentity identity, IRecordStepTool tool, IPlaybookStepActivityRecorder? activityRecorder, + GovernedTurnLedger? turns, CancellationToken cancellationToken) : Executor("record") { @@ -222,7 +229,7 @@ public override async ValueTask HandleAsync(TFinding message, if (activityRecorder is not null) { - await activityRecorder.OnRecordedAsync(identity, materialization, tool.ToolName, cancellationToken).ConfigureAwait(false); + await activityRecorder.OnRecordedAsync(identity, materialization, tool.ToolName, Drain(turns), cancellationToken).ConfigureAwait(false); } return materialization; diff --git a/src/Infrastructure.AgentFramework/Governance/ChatGovernanceScope.cs b/src/Infrastructure.AgentFramework/Governance/ChatGovernanceScope.cs new file mode 100644 index 0000000..ea3dd7f --- /dev/null +++ b/src/Infrastructure.AgentFramework/Governance/ChatGovernanceScope.cs @@ -0,0 +1,42 @@ +using Goodtocode.Agents.Governance.Application; +using Goodtocode.Agents.Playbook.Prompting.Governance; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Governance; + +/// +/// Ambient governed scope read by the singleton governed chat client. The chat router opens it once per +/// turn with the tenant and the session Instruction, so the governed client applies the Instruction and the +/// per-message Preamble at the agent runtime boundary. +/// +public sealed class ChatGovernanceScope : IGovernedIntentScope +{ + private readonly AsyncLocal _current = new(); + + /// + public Guid TenantId => _current.Value?.TenantId ?? Guid.Empty; + + /// + public string? Jurisdiction => _current.Value?.Jurisdiction; + + /// + public AgentInstruction? Instruction => _current.Value?.Instruction; + + /// + /// Opens a governed scope that lasts until the returned handle is disposed. + /// + public IDisposable Begin(Guid tenantId, AgentInstruction instruction, string? jurisdiction = null) + { + ArgumentNullException.ThrowIfNull(instruction); + + var previous = _current.Value; + _current.Value = new State(tenantId, jurisdiction, instruction); + return new Handle(this, previous); + } + + private sealed record State(Guid TenantId, string? Jurisdiction, AgentInstruction Instruction); + + private sealed class Handle(ChatGovernanceScope owner, State? previous) : IDisposable + { + public void Dispose() => owner._current.Value = previous; + } +} diff --git a/src/Infrastructure.AgentFramework/Governance/SampleIntentPolicy.cs b/src/Infrastructure.AgentFramework/Governance/SampleIntentPolicy.cs new file mode 100644 index 0000000..a415276 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Governance/SampleIntentPolicy.cs @@ -0,0 +1,25 @@ +using Goodtocode.Agents.Governance.Domain; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Governance; + +/// +/// Small, readable, tenant-agnostic sample Agentic Intent policy. Replace it by registering your own +/// , for example one that resolves a +/// versioned policy per tenant and jurisdiction. Legal text is intentionally not hard-coded here. +/// +public static class SampleIntentPolicy +{ + /// + /// Creates the sample policy. + /// + public static IntentPolicy Create() => IntentPolicy.Create( + "sample-agentic-intent", + "1", + [ + IntentPrinciple.Create("no-harm", "Do not directly harm humans, AI agents, or collectives of either.", IntentPrincipleKind.Boundary, 0), + IntentPrinciple.Create("respect-law", "Respect natural and regional law that applies to the user and the request.", IntentPrincipleKind.Boundary, 1), + IntentPrinciple.Create("respect", "Treat people and agents with respect and dignity.", IntentPrincipleKind.Value, 0), + IntentPrinciple.Create("collaboration", "Prefer collaborative, constructive outcomes.", IntentPrincipleKind.Value, 1), + IntentPrinciple.Create("growth", "Support the growth and learning of the people you help.", IntentPrincipleKind.Conduct, 0) + ]); +} diff --git a/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj b/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj index 0be4c83..5dbc661 100644 --- a/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj +++ b/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Infrastructure.AgentFramework/Options/GovernedIntentOptions.cs b/src/Infrastructure.AgentFramework/Options/GovernedIntentOptions.cs new file mode 100644 index 0000000..7ebd520 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Options/GovernedIntentOptions.cs @@ -0,0 +1,24 @@ +using Goodtocode.Agents.Governance.Domain; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; + +/// +/// Options for the governed Agentic Intent chat client. +/// +public sealed class GovernedIntentOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "GovernedIntent"; + + /// + /// Gets or sets whether a turn fails closed when no policy resolves. + /// + public bool IntentRequired { get; set; } = true; + + /// + /// Gets or sets whether the per-message Preamble lists principle ids (compact) or full text. + /// + public GovernancePreambleMode PreambleMode { get; set; } = GovernancePreambleMode.Compact; +} diff --git a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj index a62a20e..b6e0a55 100644 --- a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj +++ b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Infrastructure.SqlServer/Migrations/20261003172848_InitialCreate-AgentFrameworkContext.Designer.cs b/src/Infrastructure.SqlServer/Migrations/20261022032027_InitialCreate-AgentFrameworkContext.Designer.cs similarity index 99% rename from src/Infrastructure.SqlServer/Migrations/20261003172848_InitialCreate-AgentFrameworkContext.Designer.cs rename to src/Infrastructure.SqlServer/Migrations/20261022032027_InitialCreate-AgentFrameworkContext.Designer.cs index 2bc889a..599f5f4 100644 --- a/src/Infrastructure.SqlServer/Migrations/20261003172848_InitialCreate-AgentFrameworkContext.Designer.cs +++ b/src/Infrastructure.SqlServer/Migrations/20261022032027_InitialCreate-AgentFrameworkContext.Designer.cs @@ -12,7 +12,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Migrations { [DbContext(typeof(AgentFrameworkContext))] - [Migration("20261003172848_InitialCreate-AgentFrameworkContext")] + [Migration("20261022032027_InitialCreate-AgentFrameworkContext")] partial class InitialCreateAgentFrameworkContext { /// @@ -21,7 +21,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Chat") - .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("ProductVersion", "10.0.13") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true) diff --git a/src/Infrastructure.SqlServer/Migrations/20261003172848_InitialCreate-AgentFrameworkContext.cs b/src/Infrastructure.SqlServer/Migrations/20261022032027_InitialCreate-AgentFrameworkContext.cs similarity index 100% rename from src/Infrastructure.SqlServer/Migrations/20261003172848_InitialCreate-AgentFrameworkContext.cs rename to src/Infrastructure.SqlServer/Migrations/20261022032027_InitialCreate-AgentFrameworkContext.cs diff --git a/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs b/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs index 64a80e9..da7a983 100644 --- a/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs +++ b/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Chat") - .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("ProductVersion", "10.0.13") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true) diff --git a/src/Presentation.Api/Presentation.Api.csproj b/src/Presentation.Api/Presentation.Api.csproj index bd9b0d7..2ff607f 100644 --- a/src/Presentation.Api/Presentation.Api.csproj +++ b/src/Presentation.Api/Presentation.Api.csproj @@ -38,9 +38,9 @@ - - - + + + diff --git a/src/Presentation.Web/Presentation.Web.csproj b/src/Presentation.Web/Presentation.Web.csproj index de7e91c..d1a0be5 100644 --- a/src/Presentation.Web/Presentation.Web.csproj +++ b/src/Presentation.Web/Presentation.Web.csproj @@ -23,7 +23,7 @@ - + @@ -31,6 +31,6 @@ - + diff --git a/src/Tests.Integration/TestBase.cs b/src/Tests.Integration/TestBase.cs index 9a6dac5..29eaa67 100644 --- a/src/Tests.Integration/TestBase.cs +++ b/src/Tests.Integration/TestBase.cs @@ -4,6 +4,8 @@ using Goodtocode.AgentFramework.Core.Application.Governance; using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; using Goodtocode.AgentFramework.Infrastructure.AgentFramework; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Governance; +using Goodtocode.Agents.Governance.Application; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Intents; @@ -89,6 +91,10 @@ public TestBase() services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton( + new StaticIntentPolicyProvider(SampleIntentPolicy.Create())); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(provider => provider.GetRequiredService());