Skip to content
Closed
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
59 changes: 40 additions & 19 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type invokeFlags struct {
resumable bool
noWait bool
resume bool
steer bool
cancel bool
}

Expand Down Expand Up @@ -129,9 +130,10 @@ suppressed in raw mode.
Use --resumable with the Responses protocol to start work that continues running in
the service if this command disconnects. The command remains attached until the work
finishes. Add --no-wait to detach as soon as the service acknowledges the background
work. Use --resume to reconnect to saved work and --cancel to cancel it. In multi-agent
projects, pass the agent name positionally when resuming or cancelling saved work. Resumable
invocation is remote-only, does not support raw output, and cannot be combined with --timeout.`,
work. Use --resume to reconnect to saved work, --steer with input to revise active work
or start the next resumable turn after completion, and --cancel to cancel saved work. In
multi-agent projects, pass the agent name positionally. Resumable operations are remote-only,
do not support raw output, and cannot be combined with --timeout.`,
Example: ` # Invoke the remote agent on Foundry (auto-detects agent from azure.yaml)
azd ai agent invoke "Hello!"

Expand Down Expand Up @@ -166,8 +168,9 @@ invocation is remote-only, does not support raw output, and cannot be combined w
# Start resumable work and detach after the service acknowledges it
azd ai agent invoke --resumable --no-wait "Run the long task"

# Resume or cancel saved resumable work
# Resume, steer, or cancel saved resumable work
azd ai agent invoke --resume
azd ai agent invoke "Use the revised requirements" --steer
azd ai agent invoke --cancel

# Select an agent when reconnecting to or cancelling saved work
Expand Down Expand Up @@ -272,7 +275,7 @@ invocation is remote-only, does not support raw output, and cannot be combined w
)
}
}
if flags.resumable || flags.resume || flags.cancel {
if flags.resumable || flags.resume || flags.steer || flags.cancel {
if flags.local {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
Expand Down Expand Up @@ -345,7 +348,13 @@ invocation is remote-only, does not support raw output, and cannot be combined w
"Start resumable work that continues in the service if the command disconnects; remain attached until it finishes",
)
cmd.Flags().BoolVar(&flags.noWait, "no-wait", false, "Detach after the service acknowledges the resumable work")
cmd.Flags().BoolVar(&flags.resume, "resume", false, "Resume saved background work")
cmd.Flags().BoolVar(&flags.resume, "resume", false, "Reconnect to saved background work")
cmd.Flags().BoolVar(
&flags.steer,
"steer",
false,
"Revise active work or start the next resumable turn after completion",
)
cmd.Flags().BoolVar(&flags.cancel, "cancel", false, "Cancel the saved current background Response")

// Register `raw` as an additional allowed value on the inherited global
Expand Down Expand Up @@ -382,20 +391,21 @@ func parseInvokeArgs(flags *invokeFlags, args []string) {

func validateInvokeOperationFlags(cmd *cobra.Command, flags *invokeFlags) error {
hasInput := flags.message != "" || flags.inputFile != ""
continuesOrCancels := flags.resume || flags.cancel
messageFreeOperation := flags.resume || flags.cancel
savedResponseOperation := messageFreeOperation || flags.steer

// An invocation selects exactly one operation.
if flags.resume && flags.cancel {
if (flags.resume && (flags.steer || flags.cancel)) || (flags.steer && flags.cancel) {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--resume and --cancel are mutually exclusive",
"--resume, --steer, and --cancel are mutually exclusive",
"choose one operation",
)
}
if flags.resumable && continuesOrCancels {
if flags.resumable && savedResponseOperation {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--resumable cannot be combined with --resume or --cancel",
"--resumable cannot be combined with --resume, --steer, or --cancel",
"choose one operation",
)
}
Expand All @@ -417,14 +427,21 @@ func validateInvokeOperationFlags(cmd *cobra.Command, flags *invokeFlags) error
"provide either a message argument or --input-file, not both",
)
}
if !hasInput && !continuesOrCancels {
if flags.steer && !hasInput {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--steer requires a message argument or --input-file",
"provide revised input to steer the saved Response",
)
}
if !hasInput && !messageFreeOperation {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"a message argument or --input-file is required",
"provide a message as a positional argument, or use --input-file/-f to send a file",
)
}
if continuesOrCancels && hasInput {
if messageFreeOperation && hasInput {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--resume and --cancel do not accept a message or --input-file",
Expand All @@ -433,20 +450,20 @@ func validateInvokeOperationFlags(cmd *cobra.Command, flags *invokeFlags) error
}

// Operations on saved work own their session, conversation, and timeout.
if continuesOrCancels {
if savedResponseOperation {
for _, name := range []string{"session-id", "new-session", "conversation-id", "new-conversation"} {
if cmd.Flags().Changed(name) {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--resume and --cancel use the saved session and conversation",
"--resume, --steer, and --cancel use the saved session and conversation",
"remove session and conversation overrides",
)
}
}
if cmd.Flags().Changed("timeout") {
return exterrors.Validation(
exterrors.CodeConflictingArguments,
"--timeout is not supported with --resume or --cancel",
"--timeout is not supported with --resume, --steer, or --cancel",
"remove --timeout; attached background work has no overall timeout",
)
}
Expand Down Expand Up @@ -560,7 +577,8 @@ func (a *InvokeAction) Run(ctx context.Context) error {
// populated, but a2aRemote never calls applyCustomHeaders — the headers
// would be silently dropped, which is the exact silent no-op the guard
// intends to prevent.
if (a.flags.resumable || a.flags.resume || a.flags.cancel) && protocol != agent_api.AgentProtocolResponses {
if (a.flags.resumable || a.flags.resume || a.flags.steer || a.flags.cancel) &&
protocol != agent_api.AgentProtocolResponses {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("resumable operations are not supported with the %s protocol", protocol),
Expand Down Expand Up @@ -595,6 +613,9 @@ func (a *InvokeAction) Run(ctx context.Context) error {
case agent_api.AgentProtocolA2A:
return a.a2aRemote(ctx)
default:
if a.flags.steer {
return a.responsesSteerRemote(ctx)
}
if a.flags.resume {
return a.responsesResumeRemote(ctx)
}
Expand Down Expand Up @@ -1287,8 +1308,8 @@ func (a *InvokeAction) ensureNoActiveBackgroundResponse(
return nil
}
return fmt.Errorf(
"background Response %s is still active; reconnect with `azd ai agent invoke --resume` or cancel it with "+
"`azd ai agent invoke --cancel`",
"background Response %s is still active; reconnect with `azd ai agent invoke --resume`, revise it with "+
"`azd ai agent invoke \"<message>\" --steer`, or cancel it with `azd ai agent invoke --cancel`",
record.ResponseID,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ func classifyResponseLifecycleHTTPError(cause error, operation string) error {
switch operation {
case exterrors.OpResumeBackgroundResponse:
operationLabel = "resuming background Response"
case exterrors.OpSteerBackgroundResponse:
operationLabel = "steering background Response"
Comment thread
m5i-work marked this conversation as resolved.
case exterrors.OpCancelBackgroundResponse:
operationLabel = "cancelling background Response"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestInvokeCommandLifecycleFlagsRegistered(t *testing.T) {
t.Parallel()

flags := newInvokeCommand(nil).Flags()
for _, name := range []string{"resumable", "resume", "cancel"} {
for _, name := range []string{"resumable", "resume", "steer", "cancel"} {
flag := flags.Lookup(name)
require.NotNil(t, flag)
assert.Equal(t, "false", flag.DefValue)
Expand Down Expand Up @@ -838,6 +838,7 @@ func TestClassifyResponseLifecycleHTTPError(t *testing.T) {

for _, operation := range []string{
exterrors.OpResumeBackgroundResponse,
exterrors.OpSteerBackgroundResponse,
exterrors.OpCancelBackgroundResponse,
} {
t.Run(operation, func(t *testing.T) {
Expand Down Expand Up @@ -947,37 +948,54 @@ func TestValidateInvokeOperationFlags(t *testing.T) {
flags: invokeFlags{noWait: true, message: "hello"},
wantErr: "--no-wait requires --resumable",
},
{name: "continue accepts empty input", flags: invokeFlags{resume: true}},
{name: "resume accepts empty input", flags: invokeFlags{resume: true}},
{
name: "continue rejects message",
name: "resume rejects message",
flags: invokeFlags{resume: true, message: "hello"},
wantErr: "--resume and --cancel do not accept a message or --input-file",
},
{
name: "continue rejects file",
name: "resume rejects file",
flags: invokeFlags{resume: true, inputFile: "request.json"},
wantErr: "--resume and --cancel do not accept a message or --input-file",
},
{name: "steer accepts message", flags: invokeFlags{steer: true, message: "hello"}},
{name: "steer accepts file", flags: invokeFlags{steer: true, inputFile: "request.json"}},
{
name: "steer requires input",
flags: invokeFlags{steer: true},
wantErr: "--steer requires a message argument or --input-file",
},
{name: "cancel accepts empty input", flags: invokeFlags{cancel: true}},
{
name: "cancel rejects input",
flags: invokeFlags{cancel: true, message: "hello"},
wantErr: "--resume and --cancel do not accept a message or --input-file",
},
{
name: "continue and cancel are exclusive",
flags: invokeFlags{resume: true, cancel: true},
wantErr: "--resume and --cancel are mutually exclusive",
name: "resume and steer are exclusive",
flags: invokeFlags{resume: true, steer: true, message: "hello"},
wantErr: "--resume, --steer, and --cancel are mutually exclusive",
},
{
name: "steer and cancel are exclusive",
flags: invokeFlags{steer: true, cancel: true, message: "hello"},
wantErr: "--resume, --steer, and --cancel are mutually exclusive",
},
{
name: "background and continue are exclusive",
name: "background and resume are exclusive",
flags: invokeFlags{resumable: true, resume: true, message: "hello"},
wantErr: "--resumable cannot be combined with --resume or --cancel",
wantErr: "--resumable cannot be combined with --resume, --steer, or --cancel",
},
{
name: "background and steer are exclusive",
flags: invokeFlags{resumable: true, steer: true, message: "hello"},
wantErr: "--resumable cannot be combined with --resume, --steer, or --cancel",
},
{
name: "background and cancel are exclusive",
flags: invokeFlags{resumable: true, cancel: true, message: "hello"},
wantErr: "--resumable cannot be combined with --resume or --cancel",
wantErr: "--resumable cannot be combined with --resume, --steer, or --cancel",
},
{
name: "continue rejects session id",
Expand All @@ -991,6 +1009,12 @@ func TestValidateInvokeOperationFlags(t *testing.T) {
changed: map[string]string{"new-session": "true"},
wantErr: "use the saved session and conversation",
},
{
name: "steer rejects conversation id",
flags: invokeFlags{steer: true, message: "hello"},
changed: map[string]string{"conversation-id": "conv_123"},
wantErr: "use the saved session and conversation",
},
{
name: "cancel rejects conversation id",
flags: invokeFlags{cancel: true},
Expand All @@ -1004,16 +1028,22 @@ func TestValidateInvokeOperationFlags(t *testing.T) {
wantErr: "use the saved session and conversation",
},
{
name: "continue rejects timeout",
name: "resume rejects timeout",
flags: invokeFlags{resume: true},
changed: map[string]string{"timeout": "1"},
wantErr: "--timeout is not supported with --resume or --cancel",
wantErr: "--timeout is not supported with --resume, --steer, or --cancel",
},
{
name: "steer rejects timeout",
flags: invokeFlags{steer: true, message: "hello"},
changed: map[string]string{"timeout": "1"},
wantErr: "--timeout is not supported with --resume, --steer, or --cancel",
},
{
name: "cancel rejects timeout",
flags: invokeFlags{cancel: true},
changed: map[string]string{"timeout": "1"},
wantErr: "--timeout is not supported with --resume or --cancel",
wantErr: "--timeout is not supported with --resume, --steer, or --cancel",
},
}

Expand Down Expand Up @@ -1058,6 +1088,12 @@ func TestParseInvokeArgs(t *testing.T) {
args: []string{"agent"},
wantName: "agent",
},
{
name: "single positional with steer is message",
flags: invokeFlags{steer: true},
args: []string{"revised requirements"},
wantMessage: "revised requirements",
},
{
name: "single positional with cancel is agent",
flags: invokeFlags{cancel: true},
Expand Down
Loading
Loading