Skip to content

Commit 005b780

Browse files
MackinnonBuckCopilotSteveSandersonMS
authored
Add fine-grained system prompt customization (customize mode) (#816)
* Add fine-grained system prompt customization (customize mode) Add a new 'customize' mode for systemMessage configuration, enabling SDK consumers to selectively override individual sections of the CLI system prompt while preserving the rest. This sits between the existing 'append' and 'replace' modes. 9 configurable sections: identity, tone, tool_efficiency, environment_context, code_change_rules, guidelines, safety, tool_instructions, custom_instructions. 4 override actions per section: replace, remove, append, prepend. Unknown section IDs are handled gracefully: content-bearing overrides are appended to additional instructions with a warning, and remove on unknown sections is silently ignored. Types and constants added to all 4 SDK languages (TypeScript, Python, Go, .NET). Documentation updated across all READMEs and getting-started guide. Companion runtime PR: github/copilot-agent-runtime#4751 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback: fix docs, add examples and E2E tests - Fix incorrect package name in nodejs/README.md (@anthropic-ai/sdk -> @github/copilot-sdk) - Add standalone 'System Message Customization' sections with full code examples to Python and Go READMEs (matching TypeScript/.NET) - Add E2E tests for customize mode to Python, Go, and .NET (matching existing Node.js E2E test coverage) - Fix 'end of the prompt' wording in docs to 'additional instructions' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add last_instructions configurable section Expose lastInstructions as a customizable section across all 4 SDKs, addressing review feedback about duplicate tool-efficiency blocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix lint: prettier formatting, Python import order and line length Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add transform operation for system prompt section customization Adds a new 'transform' action to SectionOverride that enables read-then-write mutation of system prompt sections via callbacks. The SDK intercepts function- valued actions before serialization, stores the callbacks locally, and handles the batched systemMessage.transform JSON-RPC callback from the runtime. Changes across all 4 SDKs (TypeScript, Python, Go, .NET): - Types: SectionTransformFn, SectionOverrideAction (TS/Python), Transform field (Go/.NET), SectionOverrideAction constants (Go) - Client: extractTransformCallbacks helper, transform callback registration, systemMessage.transform RPC handler - Session: transform callback storage and batched dispatch with error handling - E2E tests and shared snapshot YAML files Wire protocol: single batched RPC call with all transform sections, matching the runtime implementation in copilot-agent-runtime PR #5103. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Formatting Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E snapshot for customized systemMessage config test Generate the missing snapshot file that the 'should create a session with customized systemMessage config' test requires across all SDK languages (Node, Python, Go, .NET). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Go fmt blank comment line and Python import ordering - Add blank // comment line between doc example and extractTransformCallbacks function doc comment in go/client.go (required by go fmt) - Fix ruff import sorting in python/copilot/__init__.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Python ty type error in session transform handler Use str() to ensure transform callback result is typed as str, fixing the invalid-assignment error from ty type checker at session.py:689. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Python E2E test to use keyword args for create_session The create_session() method was refactored to keyword-only params. Update the customized systemMessage test to use keyword arguments instead of a positional dict, and fix send_and_wait() call to pass prompt as a positional string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Assert transform result in system message via HTTP traffic The 'should apply transform modifications' tests previously only verified that the transform callback was invoked, not that the transformed content actually reached the model. Now all 4 SDKs assert that TRANSFORM_MARKER appears in the system message captured from HTTP traffic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Go transform JSON serialization: add json tags for content field The systemMessageTransformRequest and systemMessageTransformResponse used anonymous structs without json tags, causing Content to serialize as uppercase 'Content' instead of lowercase 'content'. The CLI expects lowercase, so transform results were silently ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>
1 parent a124096 commit 005b780

32 files changed

+1955
-45
lines changed

docs/getting-started.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ const session = await client.createSession({
12351235
12361236
### Customize the System Message
12371237

1238-
Control the AI's behavior and personality:
1238+
Control the AI's behavior and personality by appending instructions:
12391239

12401240
```typescript
12411241
const session = await client.createSession({
@@ -1245,6 +1245,28 @@ const session = await client.createSession({
12451245
});
12461246
```
12471247

1248+
For more fine-grained control, use `mode: "customize"` to override individual sections of the system prompt while preserving the rest:
1249+
1250+
```typescript
1251+
const session = await client.createSession({
1252+
systemMessage: {
1253+
mode: "customize",
1254+
sections: {
1255+
tone: { action: "replace", content: "Respond in a warm, professional tone. Be thorough in explanations." },
1256+
code_change_rules: { action: "remove" },
1257+
guidelines: { action: "append", content: "\n* Always cite data sources" },
1258+
},
1259+
content: "Focus on financial analysis and reporting.",
1260+
},
1261+
});
1262+
```
1263+
1264+
Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`, `last_instructions`.
1265+
1266+
Each override supports four actions: `replace`, `remove`, `append`, and `prepend`. Unknown section IDs are handled gracefully — content is appended to additional instructions and a warning is emitted; `remove` on unknown sections is silently ignored.
1267+
1268+
See the language-specific SDK READMEs for examples in [TypeScript](../nodejs/README.md), [Python](../python/README.md), [Go](../go/README.md), and [C#](../dotnet/README.md).
1269+
12481270
---
12491271

12501272
## Connecting to an External CLI Server

dotnet/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,34 @@ var session = await client.CreateSessionAsync(new SessionConfig
509509
});
510510
```
511511

512+
#### Customize Mode
513+
514+
Use `Mode = SystemMessageMode.Customize` to selectively override individual sections of the prompt while preserving the rest:
515+
516+
```csharp
517+
var session = await client.CreateSessionAsync(new SessionConfig
518+
{
519+
Model = "gpt-5",
520+
SystemMessage = new SystemMessageConfig
521+
{
522+
Mode = SystemMessageMode.Customize,
523+
Sections = new Dictionary<string, SectionOverride>
524+
{
525+
[SystemPromptSections.Tone] = new() { Action = SectionOverrideAction.Replace, Content = "Respond in a warm, professional tone. Be thorough in explanations." },
526+
[SystemPromptSections.CodeChangeRules] = new() { Action = SectionOverrideAction.Remove },
527+
[SystemPromptSections.Guidelines] = new() { Action = SectionOverrideAction.Append, Content = "\n* Always cite data sources" },
528+
},
529+
Content = "Focus on financial analysis and reporting."
530+
}
531+
});
532+
```
533+
534+
Available section IDs are defined as constants on `SystemPromptSections`: `Identity`, `Tone`, `ToolEfficiency`, `EnvironmentContext`, `CodeChangeRules`, `Guidelines`, `Safety`, `ToolInstructions`, `CustomInstructions`, `LastInstructions`.
535+
536+
Each section override supports four actions: `Replace`, `Remove`, `Append`, and `Prepend`. Unknown section IDs are handled gracefully: content is appended to additional instructions, and `Remove` overrides are silently ignored.
537+
538+
#### Replace Mode
539+
512540
For full control (removes all guardrails), use `Mode = SystemMessageMode.Replace`:
513541

514542
```csharp

dotnet/src/Client.cs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,44 @@ private async Task CleanupConnectionAsync(List<Exception>? errors)
365365
}
366366
}
367367

368+
private static (SystemMessageConfig? wireConfig, Dictionary<string, Func<string, Task<string>>>? callbacks) ExtractTransformCallbacks(SystemMessageConfig? systemMessage)
369+
{
370+
if (systemMessage?.Mode != SystemMessageMode.Customize || systemMessage.Sections == null)
371+
{
372+
return (systemMessage, null);
373+
}
374+
375+
var callbacks = new Dictionary<string, Func<string, Task<string>>>();
376+
var wireSections = new Dictionary<string, SectionOverride>();
377+
378+
foreach (var (sectionId, sectionOverride) in systemMessage.Sections)
379+
{
380+
if (sectionOverride.Transform != null)
381+
{
382+
callbacks[sectionId] = sectionOverride.Transform;
383+
wireSections[sectionId] = new SectionOverride { Action = SectionOverrideAction.Transform };
384+
}
385+
else
386+
{
387+
wireSections[sectionId] = sectionOverride;
388+
}
389+
}
390+
391+
if (callbacks.Count == 0)
392+
{
393+
return (systemMessage, null);
394+
}
395+
396+
var wireConfig = new SystemMessageConfig
397+
{
398+
Mode = systemMessage.Mode,
399+
Content = systemMessage.Content,
400+
Sections = wireSections
401+
};
402+
403+
return (wireConfig, callbacks);
404+
}
405+
368406
/// <summary>
369407
/// Creates a new Copilot session with the specified configuration.
370408
/// </summary>
@@ -409,6 +447,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
409447
config.Hooks.OnSessionEnd != null ||
410448
config.Hooks.OnErrorOccurred != null);
411449

450+
var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage);
451+
412452
var sessionId = config.SessionId ?? Guid.NewGuid().ToString();
413453

414454
// Create and register the session before issuing the RPC so that
@@ -424,6 +464,10 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
424464
{
425465
session.RegisterHooks(config.Hooks);
426466
}
467+
if (transformCallbacks != null)
468+
{
469+
session.RegisterTransformCallbacks(transformCallbacks);
470+
}
427471
if (config.OnEvent != null)
428472
{
429473
session.On(config.OnEvent);
@@ -440,7 +484,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
440484
config.ClientName,
441485
config.ReasoningEffort,
442486
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
443-
config.SystemMessage,
487+
wireSystemMessage,
444488
config.AvailableTools,
445489
config.ExcludedTools,
446490
config.Provider,
@@ -519,6 +563,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
519563
config.Hooks.OnSessionEnd != null ||
520564
config.Hooks.OnErrorOccurred != null);
521565

566+
var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage);
567+
522568
// Create and register the session before issuing the RPC so that
523569
// events emitted by the CLI (e.g. session.start) are not dropped.
524570
var session = new CopilotSession(sessionId, connection.Rpc, _logger);
@@ -532,6 +578,10 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
532578
{
533579
session.RegisterHooks(config.Hooks);
534580
}
581+
if (transformCallbacks != null)
582+
{
583+
session.RegisterTransformCallbacks(transformCallbacks);
584+
}
535585
if (config.OnEvent != null)
536586
{
537587
session.On(config.OnEvent);
@@ -548,7 +598,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
548598
config.Model,
549599
config.ReasoningEffort,
550600
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
551-
config.SystemMessage,
601+
wireSystemMessage,
552602
config.AvailableTools,
553603
config.ExcludedTools,
554604
config.Provider,
@@ -1222,6 +1272,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
12221272
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
12231273
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
12241274
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
1275+
rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform);
12251276
rpc.StartListening();
12261277

12271278
// Transition state to Disconnected if the JSON-RPC connection drops
@@ -1350,6 +1401,12 @@ public async Task<HooksInvokeResponse> OnHooksInvoke(string sessionId, string ho
13501401
return new HooksInvokeResponse(output);
13511402
}
13521403

1404+
public async Task<SystemMessageTransformRpcResponse> OnSystemMessageTransform(string sessionId, JsonElement sections)
1405+
{
1406+
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
1407+
return await session.HandleSystemMessageTransformAsync(sections);
1408+
}
1409+
13531410
// Protocol v2 backward-compatibility adapters
13541411

13551412
public async Task<ToolCallResponseV2> OnToolCallV2(string sessionId,
@@ -1685,6 +1742,7 @@ private static LogLevel MapLevel(TraceEventType eventType)
16851742
[JsonSerializable(typeof(ResumeSessionResponse))]
16861743
[JsonSerializable(typeof(SessionMetadata))]
16871744
[JsonSerializable(typeof(SystemMessageConfig))]
1745+
[JsonSerializable(typeof(SystemMessageTransformRpcResponse))]
16881746
[JsonSerializable(typeof(ToolCallResponseV2))]
16891747
[JsonSerializable(typeof(ToolDefinition))]
16901748
[JsonSerializable(typeof(ToolResultAIContent))]

dotnet/src/SdkProtocolVersion.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,5 @@ internal static class SdkProtocolVersion
1616
/// <summary>
1717
/// Gets the SDK protocol version.
1818
/// </summary>
19-
public static int GetVersion()
20-
{
21-
return Version;
22-
}
19+
public static int GetVersion() => Version;
2320
}

dotnet/src/Session.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public sealed partial class CopilotSession : IAsyncDisposable
6565

6666
private SessionHooks? _hooks;
6767
private readonly SemaphoreSlim _hooksLock = new(1, 1);
68+
private Dictionary<string, Func<string, Task<string>>>? _transformCallbacks;
69+
private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1);
6870
private SessionRpc? _sessionRpc;
6971
private int _isDisposed;
7072

@@ -653,6 +655,72 @@ internal void RegisterHooks(SessionHooks hooks)
653655
};
654656
}
655657

658+
/// <summary>
659+
/// Registers transform callbacks for system message sections.
660+
/// </summary>
661+
/// <param name="callbacks">The transform callbacks keyed by section identifier.</param>
662+
internal void RegisterTransformCallbacks(Dictionary<string, Func<string, Task<string>>>? callbacks)
663+
{
664+
_transformCallbacksLock.Wait();
665+
try
666+
{
667+
_transformCallbacks = callbacks;
668+
}
669+
finally
670+
{
671+
_transformCallbacksLock.Release();
672+
}
673+
}
674+
675+
/// <summary>
676+
/// Handles a systemMessage.transform RPC call from the Copilot CLI.
677+
/// </summary>
678+
/// <param name="sections">The raw JSON element containing sections to transform.</param>
679+
/// <returns>A task that resolves with the transformed sections.</returns>
680+
internal async Task<SystemMessageTransformRpcResponse> HandleSystemMessageTransformAsync(JsonElement sections)
681+
{
682+
Dictionary<string, Func<string, Task<string>>>? callbacks;
683+
await _transformCallbacksLock.WaitAsync();
684+
try
685+
{
686+
callbacks = _transformCallbacks;
687+
}
688+
finally
689+
{
690+
_transformCallbacksLock.Release();
691+
}
692+
693+
var parsed = JsonSerializer.Deserialize(
694+
sections.GetRawText(),
695+
SessionJsonContext.Default.DictionaryStringSystemMessageTransformSection) ?? new();
696+
697+
var result = new Dictionary<string, SystemMessageTransformSection>();
698+
foreach (var (sectionId, data) in parsed)
699+
{
700+
Func<string, Task<string>>? callback = null;
701+
callbacks?.TryGetValue(sectionId, out callback);
702+
703+
if (callback != null)
704+
{
705+
try
706+
{
707+
var transformed = await callback(data.Content ?? "");
708+
result[sectionId] = new SystemMessageTransformSection { Content = transformed };
709+
}
710+
catch
711+
{
712+
result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" };
713+
}
714+
}
715+
else
716+
{
717+
result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" };
718+
}
719+
}
720+
721+
return new SystemMessageTransformRpcResponse { Sections = result };
722+
}
723+
656724
/// <summary>
657725
/// Gets the complete list of messages and events in the session.
658726
/// </summary>
@@ -891,5 +959,8 @@ internal record SessionDestroyRequest
891959
[JsonSerializable(typeof(SessionEndHookOutput))]
892960
[JsonSerializable(typeof(ErrorOccurredHookInput))]
893961
[JsonSerializable(typeof(ErrorOccurredHookOutput))]
962+
[JsonSerializable(typeof(SystemMessageTransformSection))]
963+
[JsonSerializable(typeof(SystemMessageTransformRpcResponse))]
964+
[JsonSerializable(typeof(Dictionary<string, SystemMessageTransformSection>))]
894965
internal partial class SessionJsonContext : JsonSerializerContext;
895966
}

0 commit comments

Comments
 (0)