Skip to content

Commit 1653812

Browse files
Handle tool and permission broadcasts via event model (protocol v3) (#686)
1 parent 4e1499d commit 1653812

File tree

66 files changed

+7238
-1290
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+7238
-1290
lines changed

.github/workflows/publish.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414
options:
1515
- latest
1616
- prerelease
17+
- unstable
1718
version:
1819
description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments."
1920
type: string
@@ -66,8 +67,8 @@ jobs:
6667
fi
6768
else
6869
if [[ "$VERSION" != *-* ]]; then
69-
echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is 'prerelease'" >> $GITHUB_STEP_SUMMARY
70-
echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease"
70+
echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY
71+
echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable"
7172
exit 1
7273
fi
7374
fi
@@ -107,11 +108,12 @@ jobs:
107108
name: nodejs-package
108109
path: nodejs/*.tgz
109110
- name: Publish to npm
110-
if: github.ref == 'refs/heads/main'
111+
if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable'
111112
run: npm publish --tag ${{ github.event.inputs.dist-tag }} --access public --registry https://registry.npmjs.org
112113

113114
publish-dotnet:
114115
name: Publish .NET SDK
116+
if: github.event.inputs.dist-tag != 'unstable'
115117
needs: version
116118
runs-on: ubuntu-latest
117119
defaults:
@@ -147,6 +149,7 @@ jobs:
147149

148150
publish-python:
149151
name: Publish Python SDK
152+
if: github.event.inputs.dist-tag != 'unstable'
150153
needs: version
151154
runs-on: ubuntu-latest
152155
defaults:
@@ -183,7 +186,7 @@ jobs:
183186
github-release:
184187
name: Create GitHub Release
185188
needs: [version, publish-nodejs, publish-dotnet, publish-python]
186-
if: github.ref == 'refs/heads/main'
189+
if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable'
187190
runs-on: ubuntu-latest
188191
steps:
189192
- uses: actions/checkout@v6.0.2

dotnet/src/Client.cs

Lines changed: 8 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
6161
private bool _disposed;
6262
private readonly int? _optionsPort;
6363
private readonly string? _optionsHost;
64+
private int? _actualPort;
6465
private List<ModelInfo>? _modelsCache;
6566
private readonly SemaphoreSlim _modelsCacheLock = new(1, 1);
6667
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
@@ -80,6 +81,11 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
8081
? throw new ObjectDisposedException(nameof(CopilotClient))
8182
: _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first.");
8283

84+
/// <summary>
85+
/// Gets the actual TCP port the CLI server is listening on, if using TCP transport.
86+
/// </summary>
87+
public int? ActualPort => _actualPort;
88+
8389
/// <summary>
8490
/// Creates a new instance of <see cref="CopilotClient"/>.
8591
/// </summary>
@@ -191,12 +197,14 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
191197
if (_optionsHost is not null && _optionsPort is not null)
192198
{
193199
// External server (TCP)
200+
_actualPort = _optionsPort;
194201
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct);
195202
}
196203
else
197204
{
198205
// Child process (stdio or TCP)
199206
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
207+
_actualPort = portOrNull;
200208
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
201209
}
202210

@@ -1129,8 +1137,6 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
11291137
var handler = new RpcHandler(this);
11301138
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
11311139
rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle);
1132-
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall);
1133-
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest);
11341140
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
11351141
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
11361142
rpc.StartListening();
@@ -1231,116 +1237,6 @@ public void OnSessionLifecycle(string type, string sessionId, JsonElement? metad
12311237
client.DispatchLifecycleEvent(evt);
12321238
}
12331239

1234-
public async Task<ToolCallResponse> OnToolCall(string sessionId,
1235-
string toolCallId,
1236-
string toolName,
1237-
object? arguments)
1238-
{
1239-
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
1240-
if (session.GetTool(toolName) is not { } tool)
1241-
{
1242-
return new ToolCallResponse(new ToolResultObject
1243-
{
1244-
TextResultForLlm = $"Tool '{toolName}' is not supported.",
1245-
ResultType = "failure",
1246-
Error = $"tool '{toolName}' not supported"
1247-
});
1248-
}
1249-
1250-
try
1251-
{
1252-
var invocation = new ToolInvocation
1253-
{
1254-
SessionId = sessionId,
1255-
ToolCallId = toolCallId,
1256-
ToolName = toolName,
1257-
Arguments = arguments
1258-
};
1259-
1260-
// Map args from JSON into AIFunction format
1261-
var aiFunctionArgs = new AIFunctionArguments
1262-
{
1263-
Context = new Dictionary<object, object?>
1264-
{
1265-
// Allow recipient to access the raw ToolInvocation if they want, e.g., to get SessionId
1266-
// This is an alternative to using MEAI's ConfigureParameterBinding, which we can't use
1267-
// because we're not the ones producing the AIFunction.
1268-
[typeof(ToolInvocation)] = invocation
1269-
}
1270-
};
1271-
1272-
if (arguments is not null)
1273-
{
1274-
if (arguments is not JsonElement incomingJsonArgs)
1275-
{
1276-
throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}");
1277-
}
1278-
1279-
foreach (var prop in incomingJsonArgs.EnumerateObject())
1280-
{
1281-
// MEAI will deserialize the JsonElement value respecting the delegate's parameter types
1282-
aiFunctionArgs[prop.Name] = prop.Value;
1283-
}
1284-
}
1285-
1286-
var result = await tool.InvokeAsync(aiFunctionArgs);
1287-
1288-
// If the function returns a ToolResultObject, use it directly; otherwise, wrap the result
1289-
// This lets the developer provide BinaryResult, SessionLog, etc. if they deal with that themselves
1290-
var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject
1291-
{
1292-
ResultType = "success",
1293-
1294-
// In most cases, result will already have been converted to JsonElement by the AIFunction.
1295-
// We special-case string for consistency with our Node/Python/Go clients.
1296-
// TODO: I don't think it's right to special-case string here, and all the clients should
1297-
// always serialize the result to JSON (otherwise what stringification is going to happen?
1298-
// something we don't control? an error?)
1299-
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
1300-
? je.GetString()!
1301-
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
1302-
};
1303-
return new ToolCallResponse(toolResultObject);
1304-
}
1305-
catch (Exception ex)
1306-
{
1307-
return new ToolCallResponse(new()
1308-
{
1309-
// TODO: We should offer some way to control whether or not to expose detailed exception information to the LLM.
1310-
// For security, the default must be false, but developers can opt into allowing it.
1311-
TextResultForLlm = $"Invoking this tool produced an error. Detailed information is not available.",
1312-
ResultType = "failure",
1313-
Error = ex.Message
1314-
});
1315-
}
1316-
}
1317-
1318-
public async Task<PermissionRequestResponse> OnPermissionRequest(string sessionId, JsonElement permissionRequest)
1319-
{
1320-
var session = client.GetSession(sessionId);
1321-
if (session == null)
1322-
{
1323-
return new PermissionRequestResponse(new PermissionRequestResult
1324-
{
1325-
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
1326-
});
1327-
}
1328-
1329-
try
1330-
{
1331-
var result = await session.HandlePermissionRequestAsync(permissionRequest);
1332-
return new PermissionRequestResponse(result);
1333-
}
1334-
catch
1335-
{
1336-
// If permission handler fails, deny the permission
1337-
return new PermissionRequestResponse(new PermissionRequestResult
1338-
{
1339-
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
1340-
});
1341-
}
1342-
}
1343-
13441240
public async Task<UserInputRequestResponse> OnUserInputRequest(string sessionId, string question, List<string>? choices = null, bool? allowFreeform = null)
13451241
{
13461242
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
@@ -1473,12 +1369,6 @@ internal record ListSessionsRequest(
14731369
internal record ListSessionsResponse(
14741370
List<SessionMetadata> Sessions);
14751371

1476-
internal record ToolCallResponse(
1477-
ToolResultObject? Result);
1478-
1479-
internal record PermissionRequestResponse(
1480-
PermissionRequestResult Result);
1481-
14821372
internal record UserInputRequestResponse(
14831373
string Answer,
14841374
bool WasFreeform);
@@ -1578,14 +1468,12 @@ private static LogLevel MapLevel(TraceEventType eventType)
15781468
[JsonSerializable(typeof(HooksInvokeResponse))]
15791469
[JsonSerializable(typeof(ListSessionsRequest))]
15801470
[JsonSerializable(typeof(ListSessionsResponse))]
1581-
[JsonSerializable(typeof(PermissionRequestResponse))]
15821471
[JsonSerializable(typeof(PermissionRequestResult))]
15831472
[JsonSerializable(typeof(ProviderConfig))]
15841473
[JsonSerializable(typeof(ResumeSessionRequest))]
15851474
[JsonSerializable(typeof(ResumeSessionResponse))]
15861475
[JsonSerializable(typeof(SessionMetadata))]
15871476
[JsonSerializable(typeof(SystemMessageConfig))]
1588-
[JsonSerializable(typeof(ToolCallResponse))]
15891477
[JsonSerializable(typeof(ToolDefinition))]
15901478
[JsonSerializable(typeof(ToolResultAIContent))]
15911479
[JsonSerializable(typeof(ToolResultObject))]

0 commit comments

Comments
 (0)