Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ azd ai agent invocations show --protocol invocations --id <invocation-id>
azd ai agent invocations cancel --protocol invocations --id <invocation-id>
```

The Invocations-protocol lifecycle implementation is delivered in the second PR. Its existing synchronous, SSE, raw, and `202 Accepted` polling behavior on create is unchanged.
The Invocations-protocol lifecycle commands preserve the existing synchronous, SSE, raw, and `202 Accepted` polling behavior on create. Creation captures the ID from `x-agent-invocation-id`, or from the `invocation_id` property of a `202 Accepted` body. The body is preserved for the existing polling and raw-output handlers. Show and cancel use the resolved endpoint/API version, without inheriting session context from the create.

### Protocol selection

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str
return invURL
}

// buildInvocationLifecycleURL builds the Invocation retrieval URL.
func buildInvocationLifecycleURL(
projectEndpoint string,
agentName string,
invocationID string,
apiVersion string,
) string {
if apiVersion == "" {
apiVersion = DefaultAgentAPIVersion
}
return fmt.Sprintf(
"%s/agents/%s/endpoint/protocols/invocations/%s?api-version=%s",
projectEndpoint,
agentName,
url.PathEscape(invocationID),
url.QueryEscape(apiVersion),
)
}

// buildInvocationCancelURL builds the Invocation cancellation URL.
func buildInvocationCancelURL(projectEndpoint, agentName, invocationID, apiVersion string) string {
lifecycleURL := buildInvocationLifecycleURL(projectEndpoint, agentName, invocationID, apiVersion)
parts := strings.SplitN(lifecycleURL, "?", 2)
return parts[0] + "/cancel?" + parts[1]
}

// buildA2AInvokeURL builds the Foundry "a2a" protocol URL for an agent. When sid
// is non-empty, an agent_session_id query parameter is appended (URL-encoded) so
// the request routes to the same agent session, matching the invocations protocol.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func (a *DeleteAction) Run(ctx context.Context) error {
return classifyDeleteError(err, agentName)
}

// Best-effort: clean up saved session, conversation, and current Response state (same as postdown hook).
// Best-effort: clean up saved session, conversation, Response, and Invocation state (same as postdown hook).
// Must run before cleanupEnvVars since it reads AGENT_{KEY}_ENDPOINT.
if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil {
cleanupAgentState(ctx, azdClient, envResp.Environment.Name, info.ServiceName)
Expand Down
20 changes: 18 additions & 2 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/invocations.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,14 @@ func validateInvocationCommandFlags(cmd *cobra.Command, flags *invocationCommand
// supportsInvocationOperation describes implemented CLI operations, not the
// capabilities of every deployed agent. The service may still reject a request.
func supportsInvocationOperation(protocol agent_api.AgentProtocol, operation invocationOperation) bool {
return protocol == agent_api.AgentProtocolResponses &&
(operation == invocationShow || operation == invocationFollow || operation == invocationCancel)
switch protocol {
case agent_api.AgentProtocolResponses:
return operation == invocationShow || operation == invocationFollow || operation == invocationCancel
case agent_api.AgentProtocolInvocations:
return operation == invocationShow || operation == invocationCancel
default:
return false
}
}

// resolveInvocationCommand resolves a protocol before reading its current ID.
Expand Down Expand Up @@ -215,6 +221,14 @@ func resolveCurrentInvocationID(
if record != nil {
id = record.ResponseID
}
case agent_api.AgentProtocolInvocations:
record, err := newInvocationStateStore(rc.azdClient).Get(ctx, rc.agentKey)
if err != nil {
return "", classifyInvocationStateReadError(err)
}
if record != nil {
id = record.InvocationID
}
}
if id == "" {
return "", exterrors.Validation(exterrors.CodeInvalidParameter,
Expand Down Expand Up @@ -253,6 +267,8 @@ func (a *InvokeAction) runInvocationOperation(
switch agent_api.AgentProtocol(a.flags.protocol) {
case agent_api.AgentProtocolResponses:
return a.runResponseOperation(ctx, rc, id, operation, format, writer)
case agent_api.AgentProtocolInvocations:
return a.runInvocationsProtocolOperation(ctx, rc, id, operation, format, writer)
default:
return exterrors.Validation(exterrors.CodeInvalidParameter,
"unsupported invocation protocol", "select a protocol that supports this operation")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,21 +203,29 @@ func TestResolveInvocationCommandSelection(t *testing.T) {
go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop)
t.Setenv("AZD_SERVER", listener.Addr().String())
const endpoint = "https://example.services.ai.azure.com/api/projects/project/agents/agent/" +
"endpoint/protocols/openai/responses?api-version=v1"
const endpoint = "https://example.services.ai.azure.com/api/projects/project/agents/agent/endpoint/protocols/"
key := buildAgentKey("https://example.services.ai.azure.com/api/projects/project", "agent", "", false)
config.setJSON(t, responsesConfigPath, map[string]savedResponse{key: {ResponseID: "resp_current"}})
for _, tt := range []struct{ name, explicitID, want string }{
{name: "implicit", want: "resp_current"},
{name: "explicit", explicitID: "resp_explicit", want: "resp_explicit"},
config.setJSON(t, invocationsConfigPath, map[string]savedInvocation{key: {InvocationID: "inv_current"}})
for _, tt := range []struct{ name, path, protocol, explicitID, want string }{
{name: "Responses implicit", path: "openai/responses", protocol: "responses", want: "resp_current"},
{name: "Responses explicit", path: "openai/responses", protocol: "responses",
explicitID: "resp_explicit", want: "resp_explicit"},
{name: "Invocations implicit", path: "invocations", protocol: "invocations", want: "inv_current"},
{name: "Invocations explicit", path: "invocations", protocol: "invocations",
explicitID: "inv_explicit", want: "inv_explicit"},
} {
t.Run(tt.name, func(t *testing.T) {
_, rc, id, err := resolveInvocationCommand(t.Context(), &invocationCommandFlags{
agentEndpoint: endpoint, id: tt.explicitID,
action, rc, id, err := resolveInvocationCommand(t.Context(), &invocationCommandFlags{
agentEndpoint: endpoint + tt.path + "?api-version=v1", id: tt.explicitID,
}, invocationShow)
require.NoError(t, err)
defer rc.azdClient.Close()
assert.Equal(t, tt.want, id)
assert.Equal(t, tt.protocol, action.flags.protocol)
var invocations map[string]savedInvocation
config.getJSON(t, invocationsConfigPath, &invocations)
assert.Equal(t, "inv_current", invocations[key].InvocationID)
var saved map[string]savedResponse
config.getJSON(t, responsesConfigPath, &saved)
assert.Equal(t, "resp_current", saved[key].ResponseID)
Expand All @@ -231,8 +239,9 @@ func TestInvocationOperationSupport(t *testing.T) {
"activity", "invocations_ws", "voice",
} {
for _, operation := range []invocationOperation{invocationShow, invocationFollow, invocationCancel} {
assert.Equal(t, protocol == agent_api.AgentProtocolResponses,
supportsInvocationOperation(protocol, operation), "%s %s", protocol, operation)
want := protocol == agent_api.AgentProtocolResponses ||
(protocol == agent_api.AgentProtocolInvocations && operation != invocationFollow)
assert.Equal(t, want, supportsInvocationOperation(protocol, operation), "%s %s", protocol, operation)
}
}
}
Expand Down
22 changes: 14 additions & 8 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -1865,16 +1865,22 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error {
ttfb := time.Since(invokeStart)
defer resp.Body.Close()

// Print the invocation ID if the agent returned one. We do not persist it
// to the per-user config: the config store only supports the "sessions"
// and "conversations" maps (see validateStoreField), and invocation IDs
// are not used to drive any subsequent invoke -- they are emitted purely
// for trace correlation.
if !raw {
if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" {
fmt.Printf("Invocation: %s\n", invID)
invocationID, err := invocationIDFromResponse(resp)
if err != nil {
return err
}
Comment thread
m5i-work marked this conversation as resolved.
if resp.StatusCode < http.StatusBadRequest && invocationID != "" && rc.azdClient != nil && agentKey != "" {
Comment thread
m5i-work marked this conversation as resolved.
if err := newInvocationStateStore(rc.azdClient).Save(
ctx,
agentKey,
savedInvocation{InvocationID: invocationID},
); err != nil {
fmt.Fprintf(os.Stderr, "warning: Invocation %s was accepted, but its ID was not saved: %v\n", invocationID, err)
}
}
if !raw && invocationID != "" {
fmt.Printf("Invocation: %s\n", invocationID)
}

// Always capture session state from response headers (needed even in raw mode
// so subsequent invokes can reuse the session). Reads headers, not the body.
Expand Down
Loading
Loading